feat: chat history and fetching previous chats to chat pane (#698)

This commit is contained in:
MaheshtheDev 2026-01-23 22:20:32 +00:00
parent 8c195cece6
commit ebd083a65b
3 changed files with 307 additions and 12 deletions

View file

@ -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") ? (
<div className="flex items-center gap-2 md:gap-4 min-w-0 max-w-[200px] md:max-w-md">
<Logo className="h-6 block text-foreground flex-shrink-0" />
<Logo className="h-6 block text-foreground shrink-0" />
<span className="truncate text-sm md:text-base">
{getCurrentChat()?.title}
</span>

View file

@ -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<string | null>(
null,
)
const pendingFollowUpGenerations = useRef<Set<string>>(new Set())
const messagesContainerRef = useRef<HTMLDivElement>(null)
const { selectedProject } = useProject()
const { setCurrentChatId } = usePersistentChat()
const {
currentChatId,
setCurrentChatId,
setConversation,
getCurrentConversation,
} = usePersistentChat()
const lastSavedMessagesRef = useRef<typeof messages | null>(null)
const lastSavedActiveIdRef = useRef<string | null>(null)
const lastLoadedChatIdRef = useRef<string | null>(null)
const lastLoadedMessagesRef = useRef<typeof messages | null>(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({
/>
<div className="flex items-center gap-2">
{!isMobile && (
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10! border-[#73737333] bg-[#0D121A]"
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
<Dialog
open={isHistoryOpen}
onOpenChange={(open) => {
setIsHistoryOpen(open)
if (open) {
fetchThreads()
analytics.chatHistoryViewed?.()
} else {
setConfirmingDeleteId(null)
}
}}
>
<HistoryIcon className="size-4 text-[#737373]" />
</Button>
<DialogTrigger asChild>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10! border-[#73737333] bg-[#0D121A] cursor-pointer"
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
>
<HistoryIcon className="size-4 text-[#737373]" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg bg-[#0A0E14] border-[#17181AB2] text-white">
<DialogHeader className="pb-4 border-b border-[#17181AB2]">
<DialogTitle>Chat History</DialogTitle>
<DialogDescription className="text-[#737373]">
Project: {selectedProject}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-96">
{isLoadingThreads ? (
<div className="flex items-center justify-center py-8">
<SuperLoader label="Loading..." />
</div>
) : threads.length === 0 ? (
<div className="text-sm text-[#737373] text-center py-8">
No conversations yet
</div>
) : (
<div className="flex flex-col gap-1">
{threads.map((thread) => {
const isActive = thread.id === currentChatId
return (
<button
key={thread.id}
type="button"
onClick={() => loadThread(thread.id)}
className={cn(
"flex items-center justify-between rounded-md px-3 py-2 w-full text-left transition-colors",
isActive
? "bg-[#267BF1]/10"
: "hover:bg-[#17181A]",
)}
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{thread.title || "Untitled Chat"}
</div>
<div className="text-xs text-[#737373]">
{formatRelativeTime(thread.updatedAt)}
</div>
</div>
{confirmingDeleteId === thread.id ? (
<div className="flex items-center gap-1 ml-2">
<Button
type="button"
size="icon"
onClick={(e) => {
e.stopPropagation()
deleteThread(thread.id)
}}
className="bg-red-500 text-white hover:bg-red-600 h-7 w-7"
>
<Check className="size-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
setConfirmingDeleteId(null)
}}
className="h-7 w-7"
>
<XIcon className="size-3 text-[#737373]" />
</Button>
</div>
) : (
<Button
type="button"
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
setConfirmingDeleteId(thread.id)
}}
className="h-7 w-7 ml-2"
>
<Trash2 className="size-3 text-[#737373]" />
</Button>
)}
</button>
)
})}
</div>
)}
</ScrollArea>
<Button
variant="outline"
className="w-full border-dashed border-[#73737333] bg-transparent hover:bg-[#17181A]"
onClick={() => {
handleNewChat()
setIsHistoryOpen(false)
}}
>
<Plus className="size-4 mr-1" /> New Conversation
</Button>
</DialogContent>
</Dialog>
)}
<Button
variant="headers"

View file

@ -17,6 +17,7 @@ import { useMemo, useState } from "react"
import { analytics } from "@/lib/analytics"
import { useChatOpen, usePersistentChat, useProject } from "@/stores"
import { ChatMessages } from "./chat-messages"
import { generateId } from "@lib/generate-id"
export function ChatRewrite() {
const { setIsOpen } = useChatOpen()
@ -34,7 +35,7 @@ export function ChatRewrite() {
function handleNewChat() {
analytics.newChatStarted()
const newId = crypto.randomUUID()
const newId = generateId()
setCurrentChatId(newId)
setIsDialogOpen(false)
}