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 (
+
+ )
+ })}
+
+ )}
+
+ {
+ handleNewChat()
+ setIsHistoryOpen(false)
+ }}
+ >
+ New Conversation
+
+
+
)}