From ebd083a65ba33e3c9b103f787decc2faa5334f60 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:20:32 +0000 Subject: feat: chat history and fetching previous chats to chat pane (#698) --- apps/web/components/header.tsx | 5 +- apps/web/components/new/chat/index.tsx | 311 ++++++++++++++++++++++++++++++- apps/web/components/views/chat/index.tsx | 3 +- 3 files changed, 307 insertions(+), 12 deletions(-) (limited to 'apps') diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index 161b4edd..e2115aa8 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -47,6 +47,7 @@ import { ScrollArea } from "@ui/components/scroll-area" import { formatDistanceToNow } from "date-fns" import { cn } from "@lib/utils" import { useEffect, useMemo, useState } from "react" +import { generateId } from "@lib/generate-id" export function Header({ onAddMemory }: { onAddMemory?: () => void }) { const { user } = useAuth() @@ -98,7 +99,7 @@ export function Header({ onAddMemory }: { onAddMemory?: () => void }) { function handleNewChat() { analytics.newChatStarted() - const newId = crypto.randomUUID() + const newId = generateId() setCurrentChatId(newId) router.push(`/chat/${newId}`) setIsDialogOpen(false) @@ -129,7 +130,7 @@ export function Header({ onAddMemory }: { onAddMemory?: () => void }) { > {getCurrentChat()?.title && pathname.includes("/chat") ? (
- + {getCurrentChat()?.title} diff --git a/apps/web/components/new/chat/index.tsx b/apps/web/components/new/chat/index.tsx index e09cf78f..5d01af56 100644 --- a/apps/web/components/new/chat/index.tsx +++ b/apps/web/components/new/chat/index.tsx @@ -7,19 +7,33 @@ import { DefaultChatTransport } from "ai" import NovaOrb from "@/components/nova/nova-orb" import { Button } from "@ui/components/button" import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ui/components/dialog" +import { ScrollArea } from "@ui/components/scroll-area" +import { + Check, ChevronDownIcon, HistoryIcon, PanelRightCloseIcon, + Plus, SearchIcon, 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 { GradientLogo, LogoBgGradient } from "@ui/assets/Logo" import { useProject, usePersistentChat } from "@/stores" +import { areUIMessageArraysEqual } from "@/stores/chat" import type { ModelId } from "@/lib/models" import { SuperLoader } from "../../superloader" import { UserMessage } from "./message/user-message" @@ -27,6 +41,14 @@ import { AgentMessage } from "./message/agent-message" import { ChainOfThought } from "./input/chain-of-thought" import { useIsMobile } from "@hooks/use-mobile" import { analytics } from "@/lib/analytics" +import { generateId } from "@lib/generate-id" + +const DEFAULT_SUGGESTIONS = [ + "Show me all content related to Supermemory.", + "Summarize the key ideas from My Gita.", + "Which memories connect design and AI?", + "What are the main themes across my memories?", +] const DEFAULT_SUGGESTIONS = [ "Show me all content related to Supermemory.", @@ -109,10 +131,34 @@ export function ChatSidebar({ const [isInputExpanded, setIsInputExpanded] = useState(false) const [isScrolledToBottom, setIsScrolledToBottom] = useState(true) const [heightOffset, setHeightOffset] = useState(95) + const [isHistoryOpen, setIsHistoryOpen] = useState(false) + const [threads, setThreads] = useState< + Array<{ id: string; title: string; createdAt: string; updatedAt: string }> + >([]) + const [isLoadingThreads, setIsLoadingThreads] = useState(false) + const [confirmingDeleteId, setConfirmingDeleteId] = useState( + null, + ) const pendingFollowUpGenerations = useRef>(new Set()) const messagesContainerRef = useRef(null) const { selectedProject } = useProject() - const { setCurrentChatId } = usePersistentChat() + const { + currentChatId, + setCurrentChatId, + setConversation, + getCurrentConversation, + } = usePersistentChat() + const lastSavedMessagesRef = useRef(null) + const lastSavedActiveIdRef = useRef(null) + const lastLoadedChatIdRef = useRef(null) + const lastLoadedMessagesRef = useRef(null) + + // Initialize chat ID if none exists + useEffect(() => { + if (!currentChatId) { + setCurrentChatId(generateId()) + } + }, [currentChatId, setCurrentChatId]) // Adjust chat height based on scroll position (desktop only) useEffect(() => { @@ -133,6 +179,7 @@ export function ChatSidebar({ }, [isMobile]) const { messages, sendMessage, status, setMessages, stop } = useChat({ + id: currentChatId ?? undefined, transport: new DefaultChatTransport({ api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`, credentials: "include", @@ -140,6 +187,7 @@ export function ChatSidebar({ metadata: { projectId: selectedProject, model: selectedModel, + chatId: currentChatId, }, }, }), @@ -154,6 +202,59 @@ export function ChatSidebar({ }, }) + // Restore messages from store when currentChatId changes + useEffect(() => { + if (currentChatId !== lastLoadedChatIdRef.current) { + lastLoadedMessagesRef.current = null + lastSavedMessagesRef.current = null + } + + if (currentChatId === lastLoadedChatIdRef.current) { + 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 + }, [currentChatId, getCurrentConversation, setMessages]) + + // Persist messages to store whenever they change + useEffect(() => { + const activeId = currentChatId + 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, setConversation]) + // Generate follow-up questions after assistant messages are complete useEffect(() => { const generateFollowUps = async () => { @@ -310,12 +411,92 @@ export function ChatSidebar({ const handleNewChat = useCallback(() => { analytics.newChatCreated() - const newId = crypto.randomUUID() + const newId = generateId() setCurrentChatId(newId) setMessages([]) setInput("") }, [setCurrentChatId, setMessages]) + const fetchThreads = useCallback(async () => { + setIsLoadingThreads(true) + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?projectId=${selectedProject}`, + { 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) + } + }, [selectedProject]) + + const loadThread = useCallback( + async (threadId: string) => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`, + { credentials: "include" }, + ) + if (response.ok) { + const data = await response.json() + setCurrentChatId(threadId) + // Convert API messages to UIMessage format + const uiMessages = data.messages.map( + (m: { + id: string + role: string + parts: unknown + createdAt: string + }) => ({ + id: m.id, + role: m.role, + parts: m.parts || [], + createdAt: new Date(m.createdAt), + }), + ) + setMessages(uiMessages) + setConversation(threadId, uiMessages) // persist messages to store + setIsHistoryOpen(false) + setConfirmingDeleteId(null) + } + } catch (error) { + console.error("Failed to load thread:", error) + } + }, + [setCurrentChatId, setMessages, setConversation], + ) + + const deleteThread = useCallback( + async (threadId: string) => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`, + { method: "DELETE", credentials: "include" }, + ) + if (response.ok) { + setThreads((prev) => prev.filter((t) => t.id !== threadId)) + if (currentChatId === threadId) { + handleNewChat() + } + } + } catch (error) { + console.error("Failed to delete thread:", error) + } finally { + setConfirmingDeleteId(null) + } + }, + [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 @@ -476,15 +657,127 @@ export function ChatSidebar({ />
{!isMobile && ( - + + + + + + Chat History + + Project: {selectedProject} + + + + {isLoadingThreads ? ( +
+ +
+ ) : threads.length === 0 ? ( +
+ No conversations yet +
+ ) : ( +
+ {threads.map((thread) => { + const isActive = thread.id === currentChatId + return ( + + +
+ ) : ( + + )} + + ) + })} +
+ )} + + + + )}