aboutsummaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/components/header.tsx5
-rw-r--r--apps/web/components/new/chat/index.tsx311
-rw-r--r--apps/web/components/views/chat/index.tsx3
3 files changed, 307 insertions, 12 deletions
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") ? (
<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>
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<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"
diff --git a/apps/web/components/views/chat/index.tsx b/apps/web/components/views/chat/index.tsx
index 6fabadc8..005f8097 100644
--- a/apps/web/components/views/chat/index.tsx
+++ b/apps/web/components/views/chat/index.tsx
@@ -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)
}