"use client" import { useChat, useCompletion, type UIMessage } from "@ai-sdk/react" import { cn } from "@lib/utils" import { Button } from "@ui/components/button" import { DefaultChatTransport } from "ai" import { ArrowUp, Check, ChevronDown, ChevronRight, Copy, RotateCcw, X, Square, } from "lucide-react" import { useCallback, useEffect, useRef, useState } from "react" import { toast } from "sonner" import { Streamdown } from "streamdown" import { TextShimmer } from "@/components/text-shimmer" import { usePersistentChat, useProject } from "@/stores" import { useGraphHighlights } from "@/stores/highlights" import { ModelIcon } from "@/lib/models" import { Spinner } from "../../spinner" import { areUIMessageArraysEqual } from "@/stores/chat" interface MemoryResult { documentId?: string title?: string content?: string url?: string score?: number } interface ExpandableMemoriesProps { foundCount: number results: MemoryResult[] } interface MessagePart { type: string state?: string text?: string output?: { count?: number results?: Array<{ documentId?: string title?: string content?: string url?: string score?: number }> } } interface ChatMessage { id: string role: "user" | "assistant" parts: MessagePart[] } function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) { const [isExpanded, setIsExpanded] = useState(false) if (foundCount === 0) { return (
No memories found
) } return (
{isExpanded && results.length > 0 && (
{results.map((result, index) => { const isClickable = result.url && (result.url.startsWith("http://") || result.url.startsWith("https://")) const content = ( <> {result.title && (
{result.title}
)} {result.content && (
{result.content}
)} {result.url && (
{result.url}
)} {result.score && (
Score: {(result.score * 100).toFixed(1)}%
)} ) if (isClickable) { return ( {content} ) } return (
{content}
) })}
)}
) } function useStickyAutoScroll(triggerKeys: ReadonlyArray) { const scrollContainerRef = useRef(null) const bottomRef = useRef(null) const [isAutoScroll, setIsAutoScroll] = useState(true) const [isFarFromBottom, setIsFarFromBottom] = useState(false) const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => { const node = bottomRef.current if (node) node.scrollIntoView({ behavior, block: "end" }) }, []) useEffect(function observeBottomVisibility() { const container = scrollContainerRef.current const sentinel = bottomRef.current if (!container || !sentinel) return const observer = new IntersectionObserver( (entries) => { if (!entries || entries.length === 0) return const isIntersecting = entries.some((e) => e.isIntersecting) setIsAutoScroll(isIntersecting) }, { root: container, rootMargin: "0px 0px 80px 0px", threshold: 0 }, ) observer.observe(sentinel) return () => observer.disconnect() }, []) useEffect( function observeContentResize() { const container = scrollContainerRef.current if (!container) return const resizeObserver = new ResizeObserver(() => { if (isAutoScroll) scrollToBottom("auto") const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight setIsFarFromBottom(distanceFromBottom > 100) }) resizeObserver.observe(container) return () => resizeObserver.disconnect() }, [isAutoScroll, scrollToBottom], ) function enableAutoScroll() { setIsAutoScroll(true) } useEffect( function autoScrollOnNewContent() { if (isAutoScroll) scrollToBottom("auto") }, [isAutoScroll, scrollToBottom, ...triggerKeys], ) const recomputeDistanceFromBottom = useCallback(() => { const container = scrollContainerRef.current if (!container) return const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight setIsFarFromBottom(distanceFromBottom > 100) }, []) useEffect(() => { recomputeDistanceFromBottom() }, [recomputeDistanceFromBottom, ...triggerKeys]) function onScroll() { recomputeDistanceFromBottom() } return { scrollContainerRef, bottomRef, isAutoScroll, isFarFromBottom, onScroll, enableAutoScroll, scrollToBottom, } as const } export function ChatMessages() { const { selectedProject } = useProject() const { currentChatId, setCurrentChatId, setConversation, getCurrentConversation, setConversationTitle, getCurrentChat, } = usePersistentChat() const storageKey = `chat-model-${currentChatId}` const [input, setInput] = useState("") const [selectedModel, setSelectedModel] = useState< "gpt-5" | "claude-sonnet-4.5" | "gemini-2.5-pro" >("gemini-2.5-pro") const activeChatIdRef = useRef(null) const shouldGenerateTitleRef = useRef(false) const hasRunInitialMessageRef = useRef(false) const lastSavedMessagesRef = useRef(null) const lastSavedActiveIdRef = useRef(null) const lastLoadedChatIdRef = useRef(null) const lastLoadedMessagesRef = useRef(null) const { setDocumentIds } = useGraphHighlights() const { messages, sendMessage, status, stop, setMessages, id, regenerate } = useChat({ id: currentChatId ?? undefined, transport: new DefaultChatTransport({ api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat`, credentials: "include", body: { metadata: { projectId: selectedProject, model: selectedModel, chatId: currentChatId, }, }, }), onFinish: (result) => { const activeId = activeChatIdRef.current if (!activeId) return if (result.message.role !== "assistant") return if (shouldGenerateTitleRef.current) { const textPart = result.message.parts.find( (p: { type?: string; text?: string }) => p?.type === "text", ) as { text?: string } | undefined const text = textPart?.text?.trim() if (text) { shouldGenerateTitleRef.current = false complete(text) } } }, }) useEffect(() => { lastLoadedMessagesRef.current = messages }, [messages]) useEffect(() => { activeChatIdRef.current = currentChatId ?? id ?? null }, [currentChatId, id]) useEffect(() => { if (typeof window === "undefined") return if (currentChatId) { const savedModel = sessionStorage.getItem(storageKey) as | "gpt-5" | "claude-sonnet-4.5" | "gemini-2.5-pro" if ( savedModel && ["gpt-5", "claude-sonnet-4.5", "gemini-2.5-pro"].includes(savedModel) ) { setSelectedModel(savedModel) } } }, [currentChatId, storageKey]) useEffect(() => { if (typeof window === "undefined") return if (currentChatId && !hasRunInitialMessageRef.current) { // Check if there's an initial message from the home page in sessionStorage const storageKey = `chat-initial-${currentChatId}` const initialMessage = sessionStorage.getItem(storageKey) if (initialMessage) { // Clean up the storage and send the message sessionStorage.removeItem(storageKey) sendMessage({ text: initialMessage }) hasRunInitialMessageRef.current = true } } }, [currentChatId, sendMessage]) useEffect(() => { if (id && id !== currentChatId) { setCurrentChatId(id) } }, [id, currentChatId, setCurrentChatId]) useEffect(() => { if (currentChatId !== lastLoadedChatIdRef.current) { lastLoadedMessagesRef.current = null lastSavedMessagesRef.current = null } if (currentChatId === lastLoadedChatIdRef.current) { setInput("") return } const msgs = getCurrentConversation() if (msgs && msgs.length > 0) { const currentMessages = lastLoadedMessagesRef.current if (!currentMessages || !areUIMessageArraysEqual(currentMessages, msgs)) { lastLoadedMessagesRef.current = msgs setMessages(msgs) } } else if (!currentChatId) { if ( lastLoadedMessagesRef.current && lastLoadedMessagesRef.current.length > 0 ) { lastLoadedMessagesRef.current = [] setMessages([]) } } lastLoadedChatIdRef.current = currentChatId setInput("") }, [currentChatId, getCurrentConversation, setMessages]) useEffect(() => { const activeId = currentChatId ?? id if (!activeId || messages.length === 0) { return } if (activeId !== lastSavedActiveIdRef.current) { lastSavedMessagesRef.current = null lastSavedActiveIdRef.current = activeId } const lastSaved = lastSavedMessagesRef.current if (lastSaved && areUIMessageArraysEqual(lastSaved, messages)) { return } lastSavedMessagesRef.current = messages setConversation(activeId, messages) }, [messages, currentChatId, id, setConversation]) const { complete } = useCompletion({ api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/title`, credentials: "include", onFinish: (_, completion) => { const activeId = activeChatIdRef.current if (!completion || !activeId) return setConversationTitle(activeId, completion.trim()) }, }) // Update graph highlights from the most recent tool-searchMemories output useEffect(() => { try { const lastAssistant = [...messages] .reverse() .find((m) => m.role === "assistant") as ChatMessage | undefined if (!lastAssistant) return const lastSearchPart = [...(lastAssistant.parts as MessagePart[])] .reverse() .find( (p) => p?.type === "tool-searchMemories" && p?.state === "output-available", ) as MessagePart | undefined if (!lastSearchPart) return const output = lastSearchPart.output const ids = Array.isArray(output?.results) ? ((output.results as MemoryResult[]) .map((r) => r?.documentId) .filter(Boolean) as string[]) : [] if (ids.length > 0) { setDocumentIds(ids) } } catch {} }, [messages, setDocumentIds]) useEffect(() => { const currentSummary = getCurrentChat() const hasTitle = Boolean( currentSummary?.title && currentSummary.title.trim().length > 0, ) shouldGenerateTitleRef.current = !hasTitle }, [getCurrentChat]) /** * Handles sending a message from the input area. * - Prevents sending during submitted (shows toast) * - Stops streaming when active * - Validates non-empty input (shows toast) * Returns true when a message is sent. */ const handleSendMessage = useCallback(() => { if (status === "submitted") { toast.warning("Please wait for the current response to complete", { id: "wait-for-response", }) return false } if (status === "streaming") { stop() return false } if (!input.trim()) { toast.warning("Please enter a message", { id: "empty-message" }) return false } sendMessage({ text: input }) setInput("") return true }, [status, input, sendMessage, stop]) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() handleSendMessage() } } const { scrollContainerRef, bottomRef, isFarFromBottom, onScroll, enableAutoScroll, scrollToBottom, } = useStickyAutoScroll([messages, status]) return (
{messages.map((message) => (
{message.parts .filter((part) => [ "text", "tool-searchMemories", "tool-addMemory", ].includes(part.type), ) .map((part, index) => { switch (part.type) { case "text": return (
{part.text}
) case "tool-searchMemories": { switch (part.state) { case "input-available": case "input-streaming": return (
Searching memories...
) case "output-error": return (
Error recalling memories
) case "output-available": { const output = part.output const foundCount = typeof output === "object" && output !== null && "count" in output ? Number(output.count) || 0 : 0 // @ts-expect-error const results = Array.isArray(output?.results) ? // @ts-expect-error output.results : [] return ( ) } default: return null } } case "tool-addMemory": { switch (part.state) { case "input-available": return (
Adding memory...
) case "output-error": return (
Error adding memory
) case "output-available": return (
Memory added
) case "input-streaming": return (
Adding memory...
) default: return null } } default: return null } })}
{message.role === "assistant" && (
)}
))} {status === "submitted" && (
Thinking...
)}
{ e.preventDefault() const sent = handleSendMessage() if (sent) { enableAutoScroll() scrollToBottom("auto") } }} >