"use client" import { useState, useEffect, useCallback, useRef, useMemo } from "react" import { useQuery } from "@tanstack/react-query" import { $fetch } from "@lib/api" import { useQueryState } from "nuqs" import type { UIMessage } from "@ai-sdk/react" import { motion } from "motion/react" import { useChat } from "@ai-sdk/react" import { isWebSearchToolName } from "@/lib/chat-web-search-tools" import { DefaultChatTransport } from "ai" import NovaOrb from "@/components/nova/nova-orb" import { Button } from "@ui/components/button" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "@ui/components/sheet" import { ScrollArea } from "@ui/components/scroll-area" import { ArrowLeftIcon, Check, ChevronDownIcon, HistoryIcon, Plus, Search, SquarePenIcon, Trash2, XIcon, } from "lucide-react" import { formatDistanceToNow } from "date-fns" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import ChatInput from "./input" import ChatModelSelector from "./model-selector" import { getNovaChatErrorCopy } from "@/lib/chat-stream-error" import { useProject } from "@/stores" import { useContainerTags } from "@/hooks/use-container-tags" import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" import { getDefaultReasoningEffort, modelNames, type ModelId, type ReasoningEffort, } from "@/lib/models" import { SpaceSelector } from "@/components/space-selector" import { SuperLoader } from "../superloader" import { UserMessage } from "./message/user-message" import { AgentMessage, isChatToolDisplayPartType, } from "./message/agent-message" import { ChatGraphContextRail } from "./chat-graph-context-rail" import { ChainOfThought } from "./input/chain-of-thought" import { useIsMobile } from "@hooks/use-mobile" import { useAuth } from "@lib/auth-context" import { analytics } from "@/lib/analytics" import { generateId } from "@lib/generate-id" import { useViewMode } from "@/lib/view-mode-context" import { threadParam } from "@/lib/search-params" import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space" import { ChatEmptyStatePlaceholder } from "./chat-empty-state" import { toast } from "sonner" import { chatAttachmentKey, CHAT_ATTACHMENT_ACCEPT, createChatAttachmentDraft, type ChatAttachment, type ChatAttachmentDraft, isAcceptedChatAttachment, } from "./attachments" import { cacheFileBlob, removeCachedFile } from "@/lib/file-cache" import { ReasoningSelector } from "./reasoning-selector" type ChatMessageSendSource = "typed" | "suggested" | "highlight" | "home" const DISCARD_ATTACHMENT_MAX_ATTEMPTS = 15 const DISCARD_ATTACHMENT_RETRY_MS = 2000 type RawChatAttachmentResponse = Partial & { attachment?: Partial } function normalizeChatAttachmentResponse( data: RawChatAttachmentResponse, draft: ChatAttachmentDraft, ): ChatAttachment { const attachment = data.attachment ?? data const id = attachment.id ?? attachment.documentId ?? draft.id return { id, documentId: attachment.documentId, filename: attachment.filename ?? draft.file.name, mediaType: (attachment.mediaType ?? draft.file.type) || "application/octet-stream", size: attachment.size ?? draft.file.size, saveToMemory: attachment.saveToMemory ?? draft.saveToMemory, status: attachment.status ?? "ready", url: attachment.url, contentPreview: attachment.contentPreview, } } export function ChatLaunchFab({ onOpen, isMobile, }: { onOpen: () => void isMobile: boolean }) { return ( Chat with Nova ) } type QueuedChatMessage = { id: string text: string model: ModelId reasoningEffort: ReasoningEffort attachments?: ChatAttachment[] } const CHAT_QUEUE_LIMIT = 3 function normalizeModelId(value: unknown): ModelId | null { if (typeof value !== "string") return null return value in modelNames ? (value as ModelId) : null } function getMessageResponseModel(message: UIMessage): ModelId | null { const metadata = ( message as UIMessage & { metadata?: Record } ).metadata return ( normalizeModelId(metadata?.model) ?? normalizeModelId(metadata?.responseModel) ?? null ) } export function ChatSidebar({ isChatOpen, setIsChatOpen: _setIsChatOpen, queuedMessage, queuedHighlightContent, onConsumeQueuedMessage, queuedMessageSource = "highlight", queuedAttachments = null, initialSelectedModel = null, initialReasoningEffort = null, initialChatProject = null, emptyStateSuggestions, layout = "sidebar", }: { isChatOpen: boolean setIsChatOpen: (open: boolean) => void queuedMessage?: string | null queuedHighlightContent?: string | null onConsumeQueuedMessage?: () => void queuedMessageSource?: "highlight" | "home" queuedAttachments?: ChatAttachmentDraft[] | null initialSelectedModel?: ModelId | null initialReasoningEffort?: ReasoningEffort | null initialChatProject?: string | null emptyStateSuggestions?: string[] layout?: "sidebar" | "page" }) { const isMobile = useIsMobile() const isPageDesktop = layout === "page" && !isMobile const [input, setInput] = useState("") const [attachmentDrafts, setAttachmentDrafts] = useState< ChatAttachmentDraft[] >([]) const [isChatDraggingFiles, setIsChatDraggingFiles] = useState(false) const [selectedModel, setSelectedModel] = useState( initialSelectedModel ?? "grok-4.5", ) const [reasoningEffort, setReasoningEffort] = useState( initialReasoningEffort ?? getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.5"), ) const selectedModelRef = useRef(selectedModel) selectedModelRef.current = selectedModel const reasoningEffortRef = useRef(reasoningEffort) reasoningEffortRef.current = reasoningEffort const [messageQueue, setMessageQueue] = useState([]) const queuedDispatchInFlightRef = useRef(false) const queuedDispatchSawResponseRef = useRef(false) const [copiedMessageId, setCopiedMessageId] = useState(null) const [hoveredMessageId, setHoveredMessageId] = useState(null) const [messageFeedback, setMessageFeedback] = useState< Record >({}) const [expandedMemories, setExpandedMemories] = useState(null) const [isInputExpanded, setIsInputExpanded] = useState(false) const [isScrolledToBottom, setIsScrolledToBottom] = useState(true) const [heightOffset, setHeightOffset] = useState(95) const [isHistoryOpen, setIsHistoryOpen] = useState(false) const [historyScope, setHistoryScope] = useState<"current" | "all">("current") const [historySearch, setHistorySearch] = useState("") const [threads, setThreads] = useState< Array<{ id: string title: string createdAt: string updatedAt: string space?: { containerTag: string name: string emoji?: string isDefault: boolean } }> >([]) const [isLoadingThreads, setIsLoadingThreads] = useState(false) const [confirmingDeleteId, setConfirmingDeleteId] = useState( null, ) const messagesContainerRef = useRef(null) const chatDragDepthRef = useRef(0) const isScrolledToBottomRef = useRef(true) const userJustSentRef = useRef(false) const sentQueuedMessageRef = useRef(null) const truncateFromMessageIdRef = useRef(null) const pendingRegenerationRef = useRef<{ text: string } | null>(null) const pendingSendSettingsRef = useRef<{ model: ModelId reasoningEffort: ReasoningEffort } | null>(null) const pendingResponseModelsRef = useRef([]) const seenAssistantMessageIdsRef = useRef>(new Set()) const [responseModelByMessageId, setResponseModelByMessageId] = useState< Record >({}) const [regenerationBaseLength, setRegenerationBaseLength] = useState< number | null >(null) const pendingHighlightReplyRef = useRef(null) const awaitingHighlightInjectionRef = useRef(false) const pendingHighlightMessageRef = useRef(null) const targetHighlightChatIdRef = useRef(null) const pendingRequestAttachmentsRef = useRef([]) const uploadPromisesRef = useRef>>( new Map(), ) const abortControllersRef = useRef>(new Map()) const discardedDraftIdsRef = useRef>(new Set()) const { selectedProject } = useProject() const [chatSpaceProjects, setChatSpaceProjects] = useState([ initialChatProject ?? AUTO_CHAT_SPACE_ID, ]) const chatProject = chatSpaceProjects[0] ?? selectedProject const { allProjects } = useContainerTags() const selectedProjectRef = useRef(chatProject) selectedProjectRef.current = chatProject const chatSpaceLabel = useMemo( () => chatProject === AUTO_CHAT_SPACE_ID ? "Auto" : getChatSpaceDisplayLabel({ selectedProject: chatProject, allProjects, }), [chatProject, allProjects], ) const isAutoChatSpace = chatProject === AUTO_CHAT_SPACE_ID const { data: chatSpaceMemoryCount } = useQuery({ queryKey: ["chat-empty-space-count", chatProject], queryFn: async (): Promise => { const response = await $fetch("@post/documents/documents", { body: { page: 1, limit: 1, sort: "createdAt", order: "desc", containerTags: [chatProject], }, disableValidation: true, }) if (response.error) return 0 const data = response.data as { pagination?: { totalItems?: number } } | null return data?.pagination?.totalItems ?? 0 }, staleTime: 30 * 1000, enabled: !!chatProject && !isAutoChatSpace, }) const emptyStateSubtitle = useMemo(() => { if (isAutoChatSpace) { return "Picks the best space for each question" } if (chatSpaceMemoryCount === undefined) { return `Grounded in ${chatSpaceLabel}` } if (chatSpaceMemoryCount === 0) { return `Nothing in ${chatSpaceLabel} yet` } const countLabel = chatSpaceMemoryCount.toLocaleString() const memoryWord = chatSpaceMemoryCount === 1 ? "memory" : "memories" return `${countLabel} ${memoryWord} in ${chatSpaceLabel}` }, [isAutoChatSpace, chatSpaceLabel, chatSpaceMemoryCount]) const { viewMode } = useViewMode() const { user: _user } = useAuth() const [threadId, setThreadId] = useQueryState("thread", threadParam) const [fallbackChatId, setFallbackChatId] = useState(() => generateId()) const currentChatId = threadId ?? fallbackChatId const chatIdRef = useRef(currentChatId) chatIdRef.current = currentChatId const _setCurrentChatId = useCallback( (id: string) => setThreadId(id), [setThreadId], ) const chatApiBase = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" const chatTransport = useMemo( () => new DefaultChatTransport({ api: `${chatApiBase}/chat`, credentials: "include", prepareSendMessagesRequest: ({ messages }) => { const sendSettings = pendingSendSettingsRef.current pendingSendSettingsRef.current = null // Tool parts (incl. Anthropic server-side web search) don't round-trip // through convertToModelMessages and cause tool_use/tool_result mismatches. const sanitizedMessages = messages.map((m) => ({ ...m, parts: (m.parts ?? []).filter( (p) => !p.type.startsWith("tool-") && p.type !== "dynamic-tool", ), })) return { body: { messages: sanitizedMessages, metadata: { chatId: chatIdRef.current, projectId: selectedProjectRef.current, spaceMode: selectedProjectRef.current === AUTO_CHAT_SPACE_ID ? "auto" : "manual", enableSpaceDiscovery: selectedProjectRef.current === AUTO_CHAT_SPACE_ID, model: sendSettings?.model ?? selectedModelRef.current, reasoningEffort: sendSettings?.reasoningEffort ?? reasoningEffortRef.current, truncateFromMessageId: truncateFromMessageIdRef.current, ...(pendingRequestAttachmentsRef.current.length > 0 && { attachments: pendingRequestAttachmentsRef.current, }), }, }, } }, }), [chatApiBase], ) const [pendingThreadLoad, setPendingThreadLoad] = useState<{ id: string messages: UIMessage[] } | null>(null) const [loadedThreadScrollTarget, setLoadedThreadScrollTarget] = useState<{ id: string messageCount: number lastMessageId: string | null } | null>(null) // Adjust chat height based on scroll position (desktop only, grid mode only) useEffect(() => { if (isMobile) return if (viewMode === "graph") return if (layout === "page") return const handleWindowScroll = () => { const scrollThreshold = 80 const scrollY = window.scrollY const progress = Math.min(scrollY / scrollThreshold, 1) const newOffset = 95 - progress * (95 - 15) setHeightOffset(newOffset) } window.addEventListener("scroll", handleWindowScroll, { passive: true }) handleWindowScroll() return () => window.removeEventListener("scroll", handleWindowScroll) }, [isMobile, viewMode, layout]) const { messages, sendMessage, status, setMessages, stop, error, clearError, } = useChat({ id: currentChatId ?? undefined, transport: chatTransport, }) const chatStreamError = useMemo( () => (error ? getNovaChatErrorCopy(error, selectedModel) : null), [error, selectedModel], ) useEffect(() => { if (error) { pendingResponseModelsRef.current = [] } }, [error]) useEffect(() => { const updates: Record = {} for (const message of messages) { if (message.role !== "assistant") continue if (seenAssistantMessageIdsRef.current.has(message.id)) continue seenAssistantMessageIdsRef.current.add(message.id) const responseModel = getMessageResponseModel(message) ?? pendingResponseModelsRef.current.shift() ?? null if (responseModel) { updates[message.id] = responseModel } } if (Object.keys(updates).length === 0) return setResponseModelByMessageId((prev) => ({ ...prev, ...updates })) }, [messages]) const handleModelChange = useCallback( (modelId: ModelId) => { setSelectedModel(modelId) setReasoningEffort(getDefaultReasoningEffort(modelId)) clearError() }, [clearError], ) const setAttachmentDraftState = useCallback( (id: string, patch: Partial) => { setAttachmentDrafts((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)), ) }, [], ) const discardUploadedAttachment = useCallback( (documentId: string) => { void removeCachedFile(documentId) const run = async (attempt: number): Promise => { try { const response = await fetch( `${chatApiBase}/chat/attachments/${documentId}`, { method: "DELETE", credentials: "include", }, ) if ( response.status === 409 && attempt < DISCARD_ATTACHMENT_MAX_ATTEMPTS ) { setTimeout(() => { void run(attempt + 1) }, DISCARD_ATTACHMENT_RETRY_MS) return } if (!response.ok && response.status !== 404) { console.warn("Failed to discard chat attachment", { documentId, status: response.status, }) } } catch (error) { if (attempt < DISCARD_ATTACHMENT_MAX_ATTEMPTS) { setTimeout(() => { void run(attempt + 1) }, DISCARD_ATTACHMENT_RETRY_MS) return } console.warn("Failed to discard chat attachment", { documentId, error, }) } } void run(1) }, [chatApiBase], ) const uploadAttachmentDraft = useCallback( ( draft: ChatAttachmentDraft, chatIdForUpload: string, ): Promise => { if (draft.status === "uploaded" && draft.uploaded) { return Promise.resolve(draft.uploaded) } const inflight = uploadPromisesRef.current.get(draft.id) if (inflight) return inflight const uploadPromise = (async (): Promise => { const controller = new AbortController() abortControllersRef.current.set(draft.id, controller) setAttachmentDraftState(draft.id, { status: "uploading", errorMessage: undefined, }) const formData = new FormData() formData.append("file", draft.file) formData.append("threadId", chatIdForUpload) formData.append("projectId", selectedProjectRef.current) formData.append("saveToMemory", String(draft.saveToMemory)) try { const response = await fetch(`${chatApiBase}/chat/attachments`, { method: "POST", body: formData, credentials: "include", signal: controller.signal, }) if (!response.ok) { let message = "Failed to upload attachment" try { const error = (await response.json()) as { error?: string message?: string } message = error.error ?? error.message ?? message } catch { // keep the fallback error } throw new Error(message) } const data = (await response.json()) as RawChatAttachmentResponse const attachment = normalizeChatAttachmentResponse(data, draft) abortControllersRef.current.delete(draft.id) if (discardedDraftIdsRef.current.has(draft.id)) { discardedDraftIdsRef.current.delete(draft.id) if (attachment.documentId) { discardUploadedAttachment(attachment.documentId) } return attachment } if (attachment.documentId) { void cacheFileBlob( attachment.documentId, draft.file, draft.file.type, ) } setAttachmentDraftState(draft.id, { status: "uploaded", uploaded: attachment, }) return attachment } catch (error) { abortControllersRef.current.delete(draft.id) uploadPromisesRef.current.delete(draft.id) if (error instanceof DOMException && error.name === "AbortError") { throw error } if (discardedDraftIdsRef.current.has(draft.id)) { discardedDraftIdsRef.current.delete(draft.id) throw error } const message = error instanceof Error ? error.message : "Failed to upload attachment" setAttachmentDraftState(draft.id, { status: "error", errorMessage: message, }) throw error } })() uploadPromisesRef.current.set(draft.id, uploadPromise) return uploadPromise }, [chatApiBase, discardUploadedAttachment, setAttachmentDraftState], ) const uploadAttachmentDrafts = useCallback( async (drafts: ChatAttachmentDraft[], chatIdForUpload: string) => { const uploaded: ChatAttachment[] = [] for (const draft of drafts) { uploaded.push(await uploadAttachmentDraft(draft, chatIdForUpload)) } return uploaded }, [uploadAttachmentDraft], ) const handleAddAttachmentFiles = useCallback( (files: FileList | File[]) => { const incoming = Array.from(files) const accepted = incoming.filter(isAcceptedChatAttachment) const rejected = incoming.length - accepted.length if (rejected > 0) { toast.error( rejected === 1 ? "One attachment is not supported or is over 50MB" : `${rejected} attachments are not supported or are over 50MB`, ) } if (accepted.length === 0) return const existingKeys = new Set( attachmentDrafts.map((item) => chatAttachmentKey(item.file)), ) const nextItems: ChatAttachmentDraft[] = [] let duplicateCount = 0 for (const file of accepted) { const key = chatAttachmentKey(file) if (existingKeys.has(key)) { duplicateCount++ continue } existingKeys.add(key) nextItems.push(createChatAttachmentDraft(file)) } if (duplicateCount > 0) { toast.message( duplicateCount === 1 ? "Skipped duplicate attachment" : `Skipped ${duplicateCount} duplicate attachments`, ) } if (nextItems.length === 0) return setAttachmentDrafts((prev) => [...prev, ...nextItems]) for (const draft of nextItems) { void uploadAttachmentDraft(draft, currentChatId).catch(() => { // Upload errors are reflected on the draft state unless the draft was removed. }) } }, [attachmentDrafts, currentChatId, uploadAttachmentDraft], ) const handleRemoveAttachment = useCallback( (id: string) => { const draft = attachmentDrafts.find((item) => item.id === id) discardedDraftIdsRef.current.add(id) const controller = abortControllersRef.current.get(id) if (controller) { controller.abort() abortControllersRef.current.delete(id) } uploadPromisesRef.current.delete(id) const documentId = draft?.uploaded?.documentId if (draft?.status === "uploaded" && documentId) { discardUploadedAttachment(documentId) } setAttachmentDrafts((prev) => prev.filter((item) => item.id !== id)) }, [attachmentDrafts, discardUploadedAttachment], ) const handleRetryAttachment = useCallback( (id: string) => { const draft = attachmentDrafts.find((item) => item.id === id) if (!draft) return void uploadAttachmentDraft(draft, currentChatId) }, [attachmentDrafts, currentChatId, uploadAttachmentDraft], ) const hasDraggedFiles = useCallback( (event: React.DragEvent) => Array.from(event.dataTransfer.types).includes("Files"), [], ) const resetChatFileDrag = useCallback(() => { chatDragDepthRef.current = 0 setIsChatDraggingFiles(false) }, []) const handleChatDragEnter = useCallback( (event: React.DragEvent) => { if (!hasDraggedFiles(event)) return event.preventDefault() event.stopPropagation() chatDragDepthRef.current += 1 if (status !== "submitted" && status !== "streaming") { setIsChatDraggingFiles(true) } }, [hasDraggedFiles, status], ) const handleChatDragOver = useCallback( (event: React.DragEvent) => { if (!hasDraggedFiles(event)) return event.preventDefault() event.stopPropagation() event.dataTransfer.dropEffect = status === "submitted" || status === "streaming" ? "none" : "copy" }, [hasDraggedFiles, status], ) const handleChatDragLeave = useCallback( (event: React.DragEvent) => { if (!hasDraggedFiles(event)) return event.preventDefault() event.stopPropagation() chatDragDepthRef.current = Math.max(0, chatDragDepthRef.current - 1) if (chatDragDepthRef.current === 0) { setIsChatDraggingFiles(false) } }, [hasDraggedFiles], ) const handleChatDrop = useCallback( (event: React.DragEvent) => { if (!hasDraggedFiles(event)) return event.preventDefault() event.stopPropagation() resetChatFileDrag() const files = event.dataTransfer.files if (status !== "submitted" && status !== "streaming" && files.length) { handleAddAttachmentFiles(files) } }, [handleAddAttachmentFiles, hasDraggedFiles, resetChatFileDrag, status], ) useEffect(() => { if (status === "submitted" || status === "streaming") { resetChatFileDrag() } }, [resetChatFileDrag, status]) useEffect(() => { if (pendingThreadLoad && currentChatId === pendingThreadLoad.id) { setMessages(pendingThreadLoad.messages) setLoadedThreadScrollTarget({ id: pendingThreadLoad.id, messageCount: pendingThreadLoad.messages.length, lastMessageId: pendingThreadLoad.messages[pendingThreadLoad.messages.length - 1] ?.id ?? null, }) setPendingThreadLoad(null) } }, [currentChatId, pendingThreadLoad, setMessages]) const checkIfScrolledToBottom = useCallback(() => { if (!messagesContainerRef.current) return const container = messagesContainerRef.current const { scrollTop, scrollHeight, clientHeight } = container const distanceFromBottom = scrollHeight - scrollTop - clientHeight const isAtBottom = distanceFromBottom <= 20 isScrolledToBottomRef.current = isAtBottom setIsScrolledToBottom(isAtBottom) }, []) const scrollToBottom = useCallback(() => { if (messagesContainerRef.current) { messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight isScrolledToBottomRef.current = true setIsScrolledToBottom(true) } }, []) const submitChatMessage = useCallback( async ( text: string, source: ChatMessageSendSource, drafts = attachmentDrafts, ): Promise => { const trimmed = text.trim() if (!trimmed && drafts.length === 0) return false const hasBusy = drafts.some( (d) => d.status === "uploading" || d.status === "queued", ) if (hasBusy) return false const hasErrored = drafts.some((d) => d.status === "error") if (hasErrored) return false const chatIdForSend = threadId ?? fallbackChatId try { const uploadedAttachments = drafts.length > 0 ? await uploadAttachmentDrafts(drafts, chatIdForSend) : [] const messageText = trimmed || "Analyze the attached file(s)." const isRespondingNow = status === "submitted" || status === "streaming" if (isRespondingNow) { if (messageQueue.length >= CHAT_QUEUE_LIMIT) return false setMessageQueue((prev) => { if (prev.length >= CHAT_QUEUE_LIMIT) return prev return [ ...prev, { id: generateId(), text: messageText, model: selectedModel, reasoningEffort, attachments: uploadedAttachments, }, ] }) analytics.chatMessageSent({ source, attachment_count: uploadedAttachments.length, saved_attachment_count: uploadedAttachments.filter( (attachment) => attachment.saveToMemory, ).length, temporary_attachment_count: uploadedAttachments.filter( (attachment) => !attachment.saveToMemory, ).length, }) setInput("") setAttachmentDrafts([]) uploadPromisesRef.current.clear() abortControllersRef.current.clear() discardedDraftIdsRef.current.clear() userJustSentRef.current = true scrollToBottom() return true } truncateFromMessageIdRef.current = null if (!threadId) setThreadId(fallbackChatId) pendingRequestAttachmentsRef.current = uploadedAttachments analytics.chatMessageSent({ source, attachment_count: uploadedAttachments.length, saved_attachment_count: uploadedAttachments.filter( (attachment) => attachment.saveToMemory, ).length, temporary_attachment_count: uploadedAttachments.filter( (attachment) => !attachment.saveToMemory, ).length, }) setInput("") setAttachmentDrafts([]) uploadPromisesRef.current.clear() abortControllersRef.current.clear() discardedDraftIdsRef.current.clear() userJustSentRef.current = true scrollToBottom() pendingResponseModelsRef.current.push(selectedModel) void sendMessage({ text: messageText, metadata: uploadedAttachments.length > 0 ? { attachments: uploadedAttachments } : undefined, }).finally(() => { pendingRequestAttachmentsRef.current = [] }) return true } catch (error) { pendingRequestAttachmentsRef.current = [] toast.error("Failed to send message", { description: error instanceof Error ? error.message : "Please try again.", }) return false } }, [ attachmentDrafts, fallbackChatId, messageQueue.length, reasoningEffort, scrollToBottom, selectedModel, sendMessage, setThreadId, status, threadId, uploadAttachmentDrafts, ], ) const handleSend = () => { void submitChatMessage(input, "typed") } const handleSuggestedQuestion = useCallback( (suggestion: string) => { if (status === "submitted" || status === "streaming") return analytics.chatSuggestedQuestionClicked() void submitChatMessage(suggestion, "suggested", []) }, [status, submitChatMessage], ) const handleRegenerateFromUserMessage = useCallback( ( messageId: string, text: string, model: ModelId, nextReasoningEffort: ReasoningEffort, ) => { const trimmed = text.trim() if (!trimmed || status === "submitted" || status === "streaming") return const messageIndex = messages.findIndex( (message) => message.id === messageId, ) if (messageIndex === -1) return truncateFromMessageIdRef.current = messageId pendingSendSettingsRef.current = { model, reasoningEffort: nextReasoningEffort, } clearError() pendingRegenerationRef.current = { text: trimmed, } setRegenerationBaseLength(messageIndex) setMessages(messages.slice(0, messageIndex)) userJustSentRef.current = true scrollToBottom() }, [clearError, messages, scrollToBottom, setMessages, status], ) useEffect(() => { const pending = pendingRegenerationRef.current if ( !pending || regenerationBaseLength === null || messages.length !== regenerationBaseLength || status !== "ready" ) { return } pendingRegenerationRef.current = null setRegenerationBaseLength(null) analytics.chatMessageSent({ source: "typed" }) queuedDispatchInFlightRef.current = true queuedDispatchSawResponseRef.current = false pendingResponseModelsRef.current.push( pendingSendSettingsRef.current?.model ?? selectedModelRef.current, ) sendMessage({ text: pending.text }) window.setTimeout(() => { truncateFromMessageIdRef.current = null }, 0) }, [messages.length, regenerationBaseLength, sendMessage, status]) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey && !isMobile) { e.preventDefault() handleSend() } } // Keep the user message on stop so it isn't lost when generation is halted // before any assistant response arrives (ENG-732). const handleStop = useCallback(() => { stop() }, [stop]) const handleCopyMessage = useCallback((messageId: string, text: string) => { analytics.chatMessageCopied({ message_id: messageId }) navigator.clipboard.writeText(text) setCopiedMessageId(messageId) setTimeout(() => setCopiedMessageId(null), 2000) }, []) const handleLikeMessage = useCallback( (messageId: string) => { const wasLiked = messageFeedback[messageId] === "like" setMessageFeedback((prev) => ({ ...prev, [messageId]: prev[messageId] === "like" ? null : "like", })) if (!wasLiked) { analytics.chatMessageLiked({ message_id: messageId }) } }, [messageFeedback], ) const handleDislikeMessage = useCallback( (messageId: string) => { const wasDisliked = messageFeedback[messageId] === "dislike" setMessageFeedback((prev) => ({ ...prev, [messageId]: prev[messageId] === "dislike" ? null : "dislike", })) if (!wasDisliked) { analytics.chatMessageDisliked({ message_id: messageId }) } }, [messageFeedback], ) const handleToggleMemories = useCallback((messageId: string) => { setExpandedMemories((prev) => { const isExpanding = prev !== messageId if (isExpanding) { analytics.chatMemoryExpanded({ message_id: messageId }) } else { analytics.chatMemoryCollapsed({ message_id: messageId }) } return prev === messageId ? null : messageId }) }, []) const handleNewChat = useCallback(() => { analytics.newChatCreated() const newChatId = generateId() chatIdRef.current = newChatId setMessages([]) setThreadId(null) setFallbackChatId(newChatId) setInput("") setAttachmentDrafts([]) for (const controller of abortControllersRef.current.values()) { controller.abort() } abortControllersRef.current.clear() discardedDraftIdsRef.current.clear() uploadPromisesRef.current.clear() setMessageQueue([]) pendingResponseModelsRef.current = [] seenAssistantMessageIdsRef.current = new Set() setResponseModelByMessageId({}) queuedDispatchInFlightRef.current = false queuedDispatchSawResponseRef.current = false }, [setThreadId, setMessages]) const fetchThreads = useCallback(async () => { setIsLoadingThreads(true) try { const params = new URLSearchParams({ projectId: chatProject }) if (historyScope === "all") params.set("scope", "all") const response = await fetch( `${chatApiBase}/chat/threads?${params.toString()}`, { credentials: "include" }, ) if (response.ok) { const data = await response.json() setThreads(data.threads || []) } } catch (error) { console.error("Failed to fetch threads:", error) } finally { setIsLoadingThreads(false) } }, [chatApiBase, chatProject, historyScope]) useEffect(() => { if (!isHistoryOpen) return fetchThreads() analytics.chatHistoryViewed?.() }, [isHistoryOpen, fetchThreads]) const loadThread = useCallback( async (id: string) => { try { const response = await fetch(`${chatApiBase}/chat/threads/${id}`, { credentials: "include", }) if (response.ok) { const data = await response.json() const uiMessages = data.messages.map( (m: { id: string role: string parts: Array<{ type: string }> createdAt: string metadata?: Record }) => ({ id: m.id, role: m.role, // Keep chat tool outputs that are meaningful to render after thread reload. parts: (m.parts || []).filter( (p) => p.type === "text" || p.type === "reasoning" || p.type === "source-url" || p.type === "source-document" || isChatToolDisplayPartType(p.type), ), metadata: m.metadata, createdAt: new Date(m.createdAt), }), ) pendingResponseModelsRef.current = [] seenAssistantMessageIdsRef.current = new Set() setResponseModelByMessageId({}) setThreadId(id) setPendingThreadLoad({ id, messages: uiMessages }) setMessageQueue([]) queuedDispatchInFlightRef.current = false queuedDispatchSawResponseRef.current = false analytics.chatThreadLoaded({ thread_id: id }) setIsHistoryOpen(false) setConfirmingDeleteId(null) } } catch (error) { console.error("Failed to load thread:", error) } }, [chatApiBase, setThreadId], ) // Auto-restore thread from URL on mount (e.g. reload or direct link) const didAutoLoadRef = useRef(false) const initialThreadIdRef = useRef(threadId) useEffect(() => { if (didAutoLoadRef.current) return const initialThreadId = initialThreadIdRef.current if (!initialThreadId) return didAutoLoadRef.current = true loadThread(initialThreadId) }, [loadThread]) const deleteThread = useCallback( async (threadId: string) => { try { const response = await fetch( `${chatApiBase}/chat/threads/${threadId}`, { method: "DELETE", credentials: "include" }, ) if (response.ok) { analytics.chatThreadDeleted({ thread_id: threadId }) setThreads((prev) => prev.filter((t) => t.id !== threadId)) if (currentChatId === threadId) { handleNewChat() } } } catch (error) { console.error("Failed to delete thread:", error) } finally { setConfirmingDeleteId(null) } }, [chatApiBase, currentChatId, handleNewChat], ) const formatRelativeTime = (isoString: string): string => { return formatDistanceToNow(new Date(isoString), { addSuffix: true }) } useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const activeElement = document.activeElement as HTMLElement | null const isInEditableContext = activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA" || activeElement?.isContentEditable || activeElement?.closest('[contenteditable="true"]') if ( e.key.toLowerCase() === "t" && !e.metaKey && !e.ctrlKey && !e.altKey && isChatOpen && !isInEditableContext ) { e.preventDefault() handleNewChat() } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) }, [isChatOpen, handleNewChat]) // Send queued message when chat opens useEffect(() => { if ( isChatOpen && queuedMessage && status !== "submitted" && status !== "streaming" && sentQueuedMessageRef.current !== queuedMessage ) { if (initialSelectedModel && selectedModel !== initialSelectedModel) { setSelectedModel(initialSelectedModel) return } if ( initialReasoningEffort && reasoningEffort !== initialReasoningEffort ) { setReasoningEffort(initialReasoningEffort) return } sentQueuedMessageRef.current = queuedMessage if (queuedHighlightContent) { analytics.chatMessageSent({ source: queuedMessageSource }) truncateFromMessageIdRef.current = null // Start a fresh thread for highlight-based chats to avoid overwriting existing conversations const newChatId = generateId() chatIdRef.current = newChatId setThreadId(null) setFallbackChatId(newChatId) // Store the highlight message and user reply in refs. // We cannot call setMessages here because setFallbackChatId above triggers // useChat to recreate its internal Chat object (new id → new Chat), which // resets messages to []. Instead, pendingHighlightMessageRef is read by a // separate useEffect that fires after currentChatId has settled, ensuring // setMessages is called on the correct, freshly-created Chat instance. // targetHighlightChatIdRef ensures we only call setMessages once the new // Chat instance (with id=newChatId) is active, not the old one. pendingHighlightReplyRef.current = queuedMessage awaitingHighlightInjectionRef.current = true targetHighlightChatIdRef.current = newChatId pendingHighlightMessageRef.current = [ { id: generateId(), role: "assistant" as const, parts: [ { type: "text" as const, text: `Here is a highlight from your memories:\n\n${queuedHighlightContent}`, }, ], }, ] } else { if (queuedAttachments?.length) { setAttachmentDrafts(queuedAttachments) } void submitChatMessage( queuedMessage, queuedMessageSource, queuedAttachments ?? [], ).then((sent) => { if (!sent) { setInput(queuedMessage) if (queuedAttachments?.length) { setAttachmentDrafts(queuedAttachments) } } onConsumeQueuedMessage?.() }) return } onConsumeQueuedMessage?.() } }, [ isChatOpen, queuedMessage, queuedHighlightContent, queuedMessageSource, queuedAttachments, initialSelectedModel, initialReasoningEffort, selectedModel, reasoningEffort, status, onConsumeQueuedMessage, setThreadId, submitChatMessage, ]) // Inject the pending highlight assistant message once the new Chat instance is ready. // This effect must run AFTER the currentChatId change has been committed and useChat // has recreated its internal Chat object, so that setMessages targets the correct instance. // We gate on currentChatId === targetHighlightChatIdRef to ensure we call setMessages // only when useChat's internal Chat has the new id (not the old one from before setFallbackChatId). useEffect(() => { if ( awaitingHighlightInjectionRef.current && pendingHighlightMessageRef.current && targetHighlightChatIdRef.current && currentChatId === targetHighlightChatIdRef.current ) { const msgs = pendingHighlightMessageRef.current pendingHighlightMessageRef.current = null targetHighlightChatIdRef.current = null setMessages(msgs) } }, [currentChatId, setMessages]) // Send pending highlight reply once the injected assistant message is committed useEffect(() => { if ( awaitingHighlightInjectionRef.current && pendingHighlightReplyRef.current && messages.length >= 1 && messages[0]?.role === "assistant" && status === "ready" ) { awaitingHighlightInjectionRef.current = false const reply = pendingHighlightReplyRef.current pendingHighlightReplyRef.current = null truncateFromMessageIdRef.current = null queuedDispatchInFlightRef.current = true queuedDispatchSawResponseRef.current = false pendingResponseModelsRef.current.push(selectedModelRef.current) sendMessage({ text: reply }) } }, [messages, sendMessage, status]) // Reset the sent message ref when queued message is consumed useEffect(() => { if (!queuedMessage) { sentQueuedMessageRef.current = null } }, [queuedMessage]) useEffect(() => { const isRespondingNow = status === "submitted" || status === "streaming" if (isRespondingNow) { if (queuedDispatchInFlightRef.current) { queuedDispatchSawResponseRef.current = true } return } if (status !== "ready") { queuedDispatchInFlightRef.current = false queuedDispatchSawResponseRef.current = false return } if (queuedDispatchInFlightRef.current) { if (!queuedDispatchSawResponseRef.current) return queuedDispatchInFlightRef.current = false queuedDispatchSawResponseRef.current = false } const nextMessage = messageQueue[0] if (!nextMessage) return queuedDispatchInFlightRef.current = true queuedDispatchSawResponseRef.current = false truncateFromMessageIdRef.current = null pendingSendSettingsRef.current = { model: nextMessage.model, reasoningEffort: nextMessage.reasoningEffort, } pendingResponseModelsRef.current.push(nextMessage.model) if (!threadId) setThreadId(fallbackChatId) setMessageQueue((prev) => prev[0]?.id === nextMessage.id ? prev.slice(1) : prev.filter((item) => item.id !== nextMessage.id), ) pendingRequestAttachmentsRef.current = nextMessage.attachments ?? [] void sendMessage({ text: nextMessage.text, metadata: nextMessage.attachments && nextMessage.attachments.length > 0 ? { attachments: nextMessage.attachments } : undefined, }).finally(() => { pendingRequestAttachmentsRef.current = [] }) userJustSentRef.current = true scrollToBottom() }, [ fallbackChatId, messageQueue, scrollToBottom, sendMessage, setThreadId, status, threadId, ]) // Scroll to bottom when a new user message is added or a thread is loaded useEffect(() => { const lastMessageId = messages[messages.length - 1]?.id ?? null const loadedThreadIsRendered = loadedThreadScrollTarget && currentChatId === loadedThreadScrollTarget.id && messages.length === loadedThreadScrollTarget.messageCount && lastMessageId === loadedThreadScrollTarget.lastMessageId if (loadedThreadIsRendered) { // Trigger the same scroll behavior as the button after loaded messages render. scrollToBottom() setTimeout(scrollToBottom, 50) setTimeout(scrollToBottom, 150) setTimeout(() => { scrollToBottom() setLoadedThreadScrollTarget(null) }, 300) return } const lastMessage = messages[messages.length - 1] if (lastMessage?.role === "user" && messagesContainerRef.current) { scrollToBottom() } else { checkIfScrolledToBottom() } }, [ currentChatId, loadedThreadScrollTarget, messages, checkIfScrolledToBottom, scrollToBottom, ]) useEffect(() => { const isStreaming = status === "streaming" const lastMessage = messages[messages.length - 1] const isLastMessageFromAssistant = lastMessage?.role === "assistant" if ( isStreaming && isLastMessageFromAssistant && (isScrolledToBottomRef.current || userJustSentRef.current) ) { scrollToBottom() } }, [status, messages, scrollToBottom]) useEffect(() => { const container = messagesContainerRef.current if (!container) return const isStreaming = status === "streaming" || status === "submitted" if (!isStreaming) { userJustSentRef.current = false return } const mutationObserver = new MutationObserver(() => { if (isScrolledToBottomRef.current || userJustSentRef.current) { requestAnimationFrame(() => { scrollToBottom() }) } }) mutationObserver.observe(container, { childList: true, subtree: true, characterData: true, }) return () => { mutationObserver.disconnect() } }, [status, scrollToBottom]) // Add scroll event listener to track scroll position useEffect(() => { const container = messagesContainerRef.current if (!container) return const handleScroll = () => { requestAnimationFrame(() => { checkIfScrolledToBottom() if (!isScrolledToBottomRef.current) { userJustSentRef.current = false } }) } container.addEventListener("scroll", handleScroll, { passive: true }) setTimeout(() => { checkIfScrolledToBottom() }, 100) const resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { checkIfScrolledToBottom() }) }) resizeObserver.observe(container) return () => { container.removeEventListener("scroll", handleScroll) resizeObserver.disconnect() } }, [checkIfScrolledToBottom]) if (!isChatOpen) { return null } const isStackedInput = layout === "page" const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput const isResponding = status === "submitted" || status === "streaming" const isWebSearching = isResponding && (() => { const last = messages[messages.length - 1] if (!last || last.role !== "assistant") return false const parts = last.parts ?? [] const searching = parts.some( (p) => (p.type === "dynamic-tool" && isWebSearchToolName((p as { toolName?: string }).toolName ?? "")) || (p.type.startsWith("tool-") && isWebSearchToolName(p.type.slice("tool-".length))), ) const hasText = parts.some( (p) => p.type === "text" && (p as { text?: string }).text?.trim(), ) return searching && !hasText })() const hasBusyAttachment = attachmentDrafts.some( (attachment) => attachment.status === "uploading" || attachment.status === "queued", ) const hasErroredAttachment = attachmentDrafts.some( (attachment) => attachment.status === "error", ) const canSendMessage = (input.trim().length > 0 || attachmentDrafts.length > 0) && !hasBusyAttachment && !hasErroredAttachment const showInputStatusStrip = !isStackedInput || isResponding || messages.length > 0 const isQueueFull = messageQueue.length >= CHAT_QUEUE_LIMIT const threadGroups = useMemo(() => { const q = historySearch.trim().toLowerCase() const filtered = q ? threads.filter((t) => (t.title || "").toLowerCase().includes(q)) : threads const now = new Date() const startOfToday = new Date( now.getFullYear(), now.getMonth(), now.getDate(), ).getTime() const day = 86_400_000 const buckets: Array<{ label: string; items: typeof threads }> = [ { label: "Today", items: [] }, { label: "Yesterday", items: [] }, { label: "Previous 7 days", items: [] }, { label: "Previous 30 days", items: [] }, { label: "Older", items: [] }, ] for (const t of filtered) { const ts = new Date(t.updatedAt).getTime() if (ts >= startOfToday) buckets[0].items.push(t) else if (ts >= startOfToday - day) buckets[1].items.push(t) else if (ts >= startOfToday - 7 * day) buckets[2].items.push(t) else if (ts >= startOfToday - 30 * day) buckets[3].items.push(t) else buckets[4].items.push(t) } return buckets.filter((b) => b.items.length > 0) }, [threads, historySearch]) const chatHistorySheet = ( { setIsHistoryOpen(open) if (!open) { setConfirmingDeleteId(null) setHistorySearch("") } }} > button]:text-[#FAFAFA]", dmSansClassName(), )} >
Chat History {historyScope === "all" ? "All spaces" : chatSpaceLabel}
{historyScope === "all" ? "All conversations across your spaces" : `Conversations in ${chatSpaceLabel}`}
setHistorySearch(e.target.value)} placeholder="Search conversations…" className="h-9 w-full rounded-lg border border-[#161F2C] bg-[#0F141B] pr-3 pl-9 text-sm text-white placeholder:text-[#737373] focus:border-[#267BF1]/50 focus:outline-none" />
{(["current", "all"] as const).map((scope) => ( ))}
{isLoadingThreads ? (
) : threads.length === 0 ? (
No conversations yet
) : threadGroups.length === 0 ? (
No conversations match “{historySearch.trim()}”
) : (
{threadGroups.map((group) => (
{group.label}
{group.items.map((thread) => { const isActive = thread.id === currentChatId const isConfirming = confirmingDeleteId === thread.id return (
) : ( )} ) })}
))}
)}
) const chatToolbarActions = (
) const pageDesktopToolbarRow = isPageDesktop ? (
{chatToolbarActions}
) : null const chatDropOverlay = isChatDraggingFiles ? ( ) : null const chatDropTargetProps = { onDragEnter: handleChatDragEnter, onDragOver: handleChatDragOver, onDragLeave: handleChatDragLeave, onDrop: handleChatDrop, onDragEnd: resetChatFileDrag, } const shell = ( <> {showHeaderRow ? (
{isMobile && ( )} {!isStackedInput && ( <> )}
{chatToolbarActions}
) : null}
{isInputExpanded && (
)} {messages.length === 0 && ( )}
0 ? cn( "flex flex-col space-y-3 min-h-full justify-end pb-4", isPageDesktop || isMobile ? "pt-2" : "pt-14", ) : "" } > {messages.map((message, index) => ( // biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions
message.role === "assistant" && setHoveredMessageId(message.id) } onMouseLeave={() => message.role === "assistant" && setHoveredMessageId(null) } > {message.role === "user" ? ( ) : ( )}
))}
{!isScrolledToBottom && messages.length > 0 && (
)}
{chatStreamError && (

{chatStreamError.title}

{chatStreamError.body}

{chatStreamError.otherModels.length > 0 && (
{chatStreamError.otherModels.map((id) => { const m = modelNames[id] return ( ) })}
)}
)}
setInput(e.target.value)} onSend={handleSend} onStop={handleStop} onKeyDown={handleKeyDown} isResponding={isResponding} attachments={attachmentDrafts} onAddAttachmentFiles={handleAddAttachmentFiles} onRemoveAttachment={handleRemoveAttachment} onRetryAttachment={handleRetryAttachment} canSend={canSendMessage} attachmentAccept={CHAT_ATTACHMENT_ACCEPT} disableFileDropZone sendDisabled={isResponding && isQueueFull} sendDisabledTooltip={`Queue is full (${CHAT_QUEUE_LIMIT} max)`} activeStatus={ isResponding && isQueueFull ? `Queue full (${CHAT_QUEUE_LIMIT} max)` : isWebSearching ? "Searching the web…" : status === "submitted" ? "Thinking…" : status === "streaming" ? "Thinking…" : "Waiting for input…" } queuedMessages={messageQueue} showStatusStrip={showInputStatusStrip} onExpandedChange={setIsInputExpanded} chainOfThoughtComponent={ messages.length > 0 ? : null } stackedToolbar={ isStackedInput ? ( ) : undefined } toolbarTrailing={ isStackedInput ? ( ) : undefined } toolbarEnd={ isStackedInput ? ( ) : undefined } />
) return ( {!isPageDesktop && chatDropOverlay} {chatHistorySheet} {isPageDesktop ? (
{chatDropOverlay} {pageDesktopToolbarRow}
{shell}
) : ( shell )}
) } export { HomeChatComposer } from "./home-chat-composer" export { ChatEmptyStatePlaceholder } from "./chat-empty-state"