fix: merge conflicts

This commit is contained in:
Dhravya Shah 2026-01-23 17:42:47 -07:00
commit 4ca0f593a5
21 changed files with 1649 additions and 98 deletions

View file

@ -172,7 +172,7 @@ export class SupermemoryClient {
response.searchResults = {
results: (result.searchResults.results as SDKResult[]).map((r) => ({
id: r.id,
memory: limitByChars(r.content || r.context || ""),
memory: limitByChars(r.content || r.memory || r.context || ""),
similarity: r.similarity,
title: r.title,
content: r.content,

View file

@ -3,6 +3,8 @@
import { GraphDialog } from "@/components/graph-dialog"
import { Header } from "@/components/header"
import { AddMemoryView } from "@/components/views/add-memory"
import { usePathname, useRouter } from "next/navigation"
import { useFeatureFlagEnabled } from "posthog-js/react"
import { useEffect, useState } from "react"
export default function NavigationLayout({
@ -11,6 +13,16 @@ export default function NavigationLayout({
children: React.ReactNode
}) {
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const pathname = usePathname()
const router = useRouter()
const flagEnabled = useFeatureFlagEnabled("nova-alpha-access")
useEffect(() => {
if (flagEnabled && !pathname.includes("/new")) {
router.replace("/new")
}
}, [flagEnabled, router, pathname])
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement

View file

@ -37,6 +37,70 @@ function isPrivateHost(hostname: string): boolean {
return privateIpPatterns.some((pattern) => pattern.test(hostname))
}
// File extensions that are not HTML and can't be scraped for OG data
const NON_HTML_EXTENSIONS = [
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".zip",
".rar",
".7z",
".tar",
".gz",
".mp3",
".mp4",
".avi",
".mov",
".wmv",
".flv",
".webm",
".wav",
".ogg",
".jpg",
".jpeg",
".png",
".gif",
".webp",
".svg",
".ico",
".bmp",
".tiff",
".exe",
".dmg",
".iso",
".bin",
]
function isNonHtmlUrl(url: string): boolean {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname.toLowerCase()
return NON_HTML_EXTENSIONS.some((ext) => pathname.endsWith(ext))
} catch {
return false
}
}
function extractImageUrl(image: unknown): string | undefined {
if (!image) return undefined
if (typeof image === "string") {
return image
}
if (Array.isArray(image) && image.length > 0) {
const first = image[0]
if (first && typeof first === "object" && "url" in first) {
return String(first.url)
}
}
return ""
}
function extractMetaTag(html: string, patterns: RegExp[]): string {
for (const pattern of patterns) {
const match = html.match(pattern)
@ -101,6 +165,19 @@ export async function GET(request: Request) {
)
}
// Skip OG scraping for non-HTML files (PDFs, images, etc.)
if (isNonHtmlUrl(trimmedUrl)) {
return Response.json(
{ title: "", description: "" },
{
headers: {
"Cache-Control":
"public, s-maxage=3600, stale-while-revalidate=86400",
},
},
)
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 8000)

View file

@ -9,12 +9,17 @@ import { AddDocumentModal } from "@/components/new/add-document"
import { MCPModal } from "@/components/new/mcp-modal"
import { DocumentModal } from "@/components/new/document-modal"
import { DocumentsCommandPalette } from "@/components/new/documents-command-palette"
import { FullscreenNoteModal } from "@/components/new/fullscreen-note-modal"
import type { HighlightItem } from "@/components/new/highlights-card"
import { HotkeysProvider } from "react-hotkeys-hook"
import { useHotkeys } from "react-hotkeys-hook"
import { AnimatePresence } from "motion/react"
import { useIsMobile } from "@hooks/use-mobile"
import { useProject } from "@/stores"
import { useQuickNoteDraftReset } from "@/stores/quick-note-draft"
import { analytics } from "@/lib/analytics"
import { useDocumentMutations } from "@/hooks/use-document-mutations"
import { useQuery } from "@tanstack/react-query"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
@ -31,6 +36,56 @@ export default function NewPage() {
useState<DocumentWithMemories | null>(null)
const [isDocumentModalOpen, setIsDocumentModalOpen] = useState(false)
const [isFullScreenNoteOpen, setIsFullScreenNoteOpen] = useState(false)
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(null)
const [searchPrefill, setSearchPrefill] = useState("")
const resetDraft = useQuickNoteDraftReset(selectedProject)
const { noteMutation } = useDocumentMutations({
onClose: () => {
resetDraft()
setIsFullScreenNoteOpen(false)
},
})
// Fetch space highlights (highlights + suggested questions)
type SpaceHighlightsResponse = {
highlights: HighlightItem[]
questions: string[]
generatedAt: string
}
const { data: highlightsData, isLoading: isLoadingHighlights } =
useQuery<SpaceHighlightsResponse>({
queryKey: ["space-highlights", selectedProject],
queryFn: async (): Promise<SpaceHighlightsResponse> => {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
spaceId: selectedProject || "sm_project_default",
highlightsCount: 3,
questionsCount: 4,
includeHighlights: true,
includeQuestions: true,
}),
},
)
if (!response.ok) {
throw new Error("Failed to fetch space highlights")
}
return response.json()
},
staleTime: 4 * 60 * 60 * 1000, // 4 hours (matches backend cache)
refetchOnWindowFocus: false,
})
useHotkeys("c", () => {
analytics.addDocumentModalOpened()
setIsAddDocumentOpen(true)
@ -46,6 +101,42 @@ export default function NewPage() {
setIsDocumentModalOpen(true)
}, [])
const handleQuickNoteSave = useCallback(
(content: string) => {
if (content.trim()) {
noteMutation.mutate({ content, project: selectedProject })
}
},
[selectedProject, noteMutation],
)
const handleFullScreenSave = useCallback(
(content: string) => {
if (content.trim()) {
noteMutation.mutate({ content, project: selectedProject })
}
},
[selectedProject, noteMutation],
)
const handleMaximize = useCallback(
(content: string) => {
setFullscreenInitialContent(content)
setIsFullScreenNoteOpen(true)
},
[],
)
const handleHighlightsChat = useCallback((seed: string) => {
setQueuedChatSeed(seed)
setIsChatOpen(true)
}, [])
const handleHighlightsShowRelated = useCallback((query: string) => {
setSearchPrefill(query)
setIsSearchOpen(true)
}, [])
return (
<HotkeysProvider>
<div className="bg-black min-h-screen">
@ -69,10 +160,21 @@ export default function NewPage() {
key={`main-container-${isChatOpen}`}
className="z-10 flex flex-col md:flex-row relative"
>
<div className="flex-1 p-4 md:p-6 md:pr-0">
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
<MemoriesGrid
isChatOpen={isChatOpen}
onOpenDocument={handleOpenDocument}
quickNoteProps={{
onSave: handleQuickNoteSave,
onMaximize: handleMaximize,
isSaving: noteMutation.isPending,
}}
highlightsProps={{
items: highlightsData?.highlights || [],
onChat: handleHighlightsChat,
onShowRelated: handleHighlightsShowRelated,
isLoading: isLoadingHighlights,
}}
/>
</div>
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
@ -80,13 +182,22 @@ export default function NewPage() {
<ChatSidebar
isChatOpen={isChatOpen}
setIsChatOpen={setIsChatOpen}
queuedMessage={queuedChatSeed}
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
emptyStateSuggestions={highlightsData?.questions}
/>
</AnimatePresence>
</div>
</main>
{isMobile && (
<ChatSidebar isChatOpen={isChatOpen} setIsChatOpen={setIsChatOpen} />
<ChatSidebar
isChatOpen={isChatOpen}
setIsChatOpen={setIsChatOpen}
queuedMessage={queuedChatSeed}
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
emptyStateSuggestions={highlightsData?.questions}
/>
)}
<AddDocumentModal
@ -99,15 +210,26 @@ export default function NewPage() {
/>
<DocumentsCommandPalette
open={isSearchOpen}
onOpenChange={setIsSearchOpen}
onOpenChange={(open) => {
setIsSearchOpen(open)
if (!open) setSearchPrefill("")
}}
projectId={selectedProject}
onOpenDocument={handleOpenDocument}
initialSearch={searchPrefill}
/>
<DocumentModal
document={selectedDocument}
isOpen={isDocumentModalOpen}
onClose={() => setIsDocumentModalOpen(false)}
/>
<FullscreenNoteModal
isOpen={isFullScreenNoteOpen}
onClose={() => setIsFullScreenNoteOpen(false)}
initialContent={fullscreenInitialContent}
onSave={handleFullScreenSave}
isSaving={noteMutation.isPending}
/>
</div>
</HotkeysProvider>
)

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,19 +41,22 @@ 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?",
]
function ChatEmptyStatePlaceholder({
onSuggestionClick,
suggestions = DEFAULT_SUGGESTIONS,
}: {
onSuggestionClick: (suggestion: string) => void
suggestions?: string[]
}) {
const 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?",
]
return (
<div
id="chat-empty-state"
@ -61,11 +78,13 @@ function ChatEmptyStatePlaceholder({
<Button
key={suggestion}
variant="default"
className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit max-w-[400px] py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
onClick={() => onSuggestionClick(suggestion)}
>
<SearchIcon className="size-4 text-[#267BF1]" />
<span className="text-[#267BF1] text-[12px]">{suggestion}</span>
<SearchIcon className="size-4 text-[#267BF1] shrink-0" />
<span className="text-[#267BF1] text-[12px] truncate">
{suggestion}
</span>
</Button>
))}
</div>
@ -77,9 +96,15 @@ function ChatEmptyStatePlaceholder({
export function ChatSidebar({
isChatOpen,
setIsChatOpen,
queuedMessage,
onConsumeQueuedMessage,
emptyStateSuggestions,
}: {
isChatOpen: boolean
setIsChatOpen: (open: boolean) => void
queuedMessage?: string | null
onConsumeQueuedMessage?: () => void
emptyStateSuggestions?: string[]
}) {
const isMobile = useIsMobile()
const [input, setInput] = useState("")
@ -99,10 +124,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(() => {
@ -123,6 +172,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",
@ -130,6 +180,7 @@ export function ChatSidebar({
metadata: {
projectId: selectedProject,
model: selectedModel,
chatId: currentChatId,
},
},
}),
@ -144,6 +195,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 () => {
@ -300,12 +404,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
@ -332,6 +516,19 @@ export function ChatSidebar({
return () => window.removeEventListener("keydown", handleKeyDown)
}, [isChatOpen, handleNewChat])
// Send queued message when chat opens
useEffect(() => {
if (
isChatOpen &&
queuedMessage &&
status !== "submitted" &&
status !== "streaming"
) {
sendMessage({ text: queuedMessage })
onConsumeQueuedMessage?.()
}
}, [isChatOpen, queuedMessage, status, sendMessage, onConsumeQueuedMessage])
// Scroll to bottom when a new user message is added
useEffect(() => {
const lastMessage = messages[messages.length - 1]
@ -384,7 +581,7 @@ export function ChatSidebar({
"flex items-start justify-start",
isMobile
? "fixed bottom-5 right-0 left-0 z-50 justify-center items-center"
: "absolute top-0 right-0 m-4",
: "absolute top-[-10px] right-0 m-4",
dmSansClassName(),
)}
layoutId="chat-toggle-button"
@ -453,15 +650,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"
@ -526,6 +835,7 @@ export function ChatSidebar({
onSuggestionClick={(suggestion) => {
sendMessage({ text: suggestion })
}}
suggestions={emptyStateSuggestions}
/>
)}
<div

View file

@ -76,7 +76,7 @@ export function AgentMessage({
return (
<div
key={`${message.id}-${partIndex}`}
className="text-xs text-white/50 italic"
className="text-xs text-white italic"
>
Searching memories...
</div>

View file

@ -2,16 +2,119 @@
import { Suspense } from "react"
import type { Tweet } from "react-tweet/api"
import {
TweetContainer,
TweetHeader,
TweetBody,
TweetMedia,
enrichTweet,
TweetSkeleton,
} from "react-tweet"
import { TweetBody, enrichTweet, TweetSkeleton } from "react-tweet"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { PlayCircle } from "lucide-react"
function VerifiedBadge({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 22 22"
aria-label="Verified account"
className={cn("size-3", className)}
>
<title>Verified</title>
<g>
<path
fill="#1D9BF0"
d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"
/>
</g>
</svg>
)
}
function XLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={cn("size-3 fill-white", className)}
>
<title>X</title>
<g>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</g>
</svg>
)
}
function CustomTweetHeader({
tweet,
}: {
tweet: ReturnType<typeof enrichTweet>
}) {
const user = tweet.user
const isVerified = user.verified || user.is_blue_verified
return (
<div className="flex items-start justify-between pr-0.5 w-full">
<div className="flex gap-2 items-center">
<div className="bg-white overflow-hidden rounded-full shrink-0 size-[30px]">
<img
src={user.profile_image_url_https}
alt={user.name}
className="size-full object-cover"
/>
</div>
<div className="flex flex-col items-start">
<div className="flex gap-0.5 items-center">
<p
className={cn(
"font-semibold leading-tight overflow-hidden text-[#fafafa] text-[12px] truncate tracking-[-0.12px]",
dmSansClassName(),
)}
>
{user.name}
</p>
{isVerified && <VerifiedBadge />}
</div>
<p
className={cn(
"font-medium leading-tight overflow-hidden text-[#737373] text-[12px] truncate tracking-[-0.12px]",
dmSansClassName(),
)}
>
@{user.screen_name}
</p>
</div>
</div>
<div className="flex gap-1.5 items-center">
<XLogo />
</div>
</div>
)
}
function CustomTweetMedia({
tweet,
}: {
tweet: ReturnType<typeof enrichTweet>
}) {
const media = tweet.mediaDetails?.[0]
if (!media) return null
const isVideo = media.type === "video" || media.type === "animated_gif"
const previewUrl = media.media_url_https
return (
<div className="relative w-full overflow-hidden rounded-[6px] border border-[rgba(47,50,54,0.2)]">
<div className="relative w-full aspect-video">
<img
src={previewUrl}
alt="Tweet media"
className="w-full h-full object-cover"
/>
{isVideo && (
<div className="absolute inset-0 bg-[rgba(4,5,5,0.8)] flex items-center justify-center">
<PlayCircle className="size-8 text-white" strokeWidth={1.5} />
</div>
)}
</div>
</div>
)
}
export function TweetPreview({
data,
@ -26,25 +129,19 @@ export function TweetPreview({
return (
<div
className={cn(
"p-3 sm-tweet-theme w-full min-w-0",
noBgColor
? "bg-transparent rounded-none"
: "bg-[#0B1017]! rounded-[18px]",
"w-full min-w-0",
noBgColor ? "bg-transparent" : "bg-black rounded-[18px] p-3",
dmSansClassName(),
)}
>
<Suspense fallback={<TweetSkeleton />}>
<TweetContainer
className={cn(
"pb-0! my-0! bg-transparent! border-none! w-full! min-w-0!",
dmSansClassName(),
)}
>
<TweetHeader tweet={tweet} components={{}} />
<TweetBody tweet={tweet} />
{tweet.mediaDetails?.length ? (
<TweetMedia tweet={tweet} components={{}} />
) : null}
</TweetContainer>
<div className="flex flex-col gap-3 w-full">
<CustomTweetHeader tweet={tweet} />
<div className="sm-tweet-theme w-full min-w-0">
<TweetBody tweet={tweet} />
</div>
<CustomTweetMedia tweet={tweet} />
</div>
</Suspense>
</div>
)

View file

@ -19,6 +19,7 @@ interface DocumentsCommandPaletteProps {
onOpenChange: (open: boolean) => void
projectId: string
onOpenDocument: (document: DocumentWithMemories) => void
initialSearch?: string
}
export function DocumentsCommandPalette({
@ -26,6 +27,7 @@ export function DocumentsCommandPalette({
onOpenChange,
projectId,
onOpenDocument,
initialSearch = "",
}: DocumentsCommandPaletteProps) {
const isMobile = useIsMobile()
const queryClient = useQueryClient()
@ -47,10 +49,10 @@ export function DocumentsCommandPalette({
setDocuments(queryData.pages.flatMap((page) => page.documents ?? []))
}
setTimeout(() => inputRef.current?.focus(), 0)
setSearch("")
setSearch(initialSearch)
setSelectedIndex(0)
}
}, [open, queryClient, projectId])
}, [open, queryClient, projectId, initialSearch])
const filteredDocuments = useMemo(() => {
if (!search.trim()) return documents

View file

@ -0,0 +1,223 @@
"use client"
import { useState, useCallback, useEffect, useRef } from "react"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { Logo } from "@ui/assets/Logo"
import { Minimize2, Plus, Loader2 } from "lucide-react"
import { useAuth } from "@lib/auth-context"
import { TextEditor } from "./text-editor"
import { useProject } from "@/stores"
import { useQuickNoteDraft } from "@/stores/quick-note-draft"
interface FullscreenNoteModalProps {
isOpen: boolean
onClose: () => void
initialContent?: string
onSave: (content: string) => void
isSaving?: boolean
}
export function FullscreenNoteModal({
isOpen,
onClose,
initialContent = "",
onSave,
isSaving = false,
}: FullscreenNoteModalProps) {
const { user } = useAuth()
const { selectedProject } = useProject()
const { setDraft } = useQuickNoteDraft(selectedProject)
const [content, setContent] = useState(initialContent)
const contentRef = useRef(content)
useEffect(() => {
contentRef.current = content
}, [content])
useEffect(() => {
if (isOpen) {
setContent(initialContent)
}
}, [isOpen, initialContent])
const displayName =
user?.displayUsername ||
(typeof window !== "undefined" && localStorage.getItem("username")) ||
(typeof window !== "undefined" && localStorage.getItem("userName")) ||
""
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
const handleSave = useCallback(() => {
const currentContent = contentRef.current
if (currentContent.trim() && !isSaving) {
onSave(currentContent)
}
}, [isSaving, onSave])
const handleContentChange = useCallback(
(newContent: string) => {
console.log("handleContentChange", newContent)
setContent(newContent)
setDraft(newContent)
},
[setDraft],
)
const canSave = content.trim().length > 0 && !isSaving
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
e.preventDefault()
onClose()
}
}
if (isOpen) {
document.addEventListener("keydown", handleKeyDown)
}
return () => {
document.removeEventListener("keydown", handleKeyDown)
}
}, [isOpen, onClose])
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className={cn(
"border-none bg-[#0D121A] flex flex-col p-0 gap-0",
"w-screen! h-screen! max-w-none! max-h-none! rounded-none",
dmSansClassName(),
)}
showCloseButton={false}
>
<DialogTitle className="sr-only">New Note</DialogTitle>
<header className="flex justify-between items-center p-3 md:p-4">
<div className="flex items-center gap-2">
<Logo className="h-7" />
{userName && (
<div className="flex flex-col items-start justify-center ml-2">
<p className="text-[#8B8B8B] text-[11px] leading-tight">
{userName}
</p>
<p className="text-white font-bold text-xl leading-none -mt-1">
supermemory
</p>
</div>
)}
</div>
<div
id="fullscreen-close-controls"
className="bg-[#1B1F24] rounded-[8px] px-3 py-2.5 flex items-center gap-2.5"
style={{
boxShadow:
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
}}
>
<span
className={cn(
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center justify-center h-4",
)}
>
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#737373]",
)}
>
ESC
</span>
</span>
<button
type="button"
onClick={onClose}
className="text-[#fafafa] hover:text-white transition-colors cursor-pointer"
aria-label="Close full screen"
>
<Minimize2 className="size-6" />
</button>
</div>
</header>
<main className="flex-1 flex flex-col items-center px-4 md:px-[288px] pt-8 md:pt-12 pb-24 overflow-auto">
<div className="w-full max-w-[864px] space-y-4">
<div className="min-h-[400px] flex-1">
<TextEditor
content={initialContent || undefined}
onContentChange={handleContentChange}
onSubmit={handleSave}
/>
</div>
</div>
</main>
<div
id="fullscreen-save-bar"
className="fixed bottom-8 left-1/2 -translate-x-1/2"
>
<button
type="button"
onClick={handleSave}
disabled={!canSave}
className={cn(
"bg-[#1B1F24] rounded-[8px] px-4 py-2.5 flex items-center justify-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
style={{
boxShadow:
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
}}
>
{isSaving ? (
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
) : (
<Plus className="size-2 text-[#fafafa]" />
)}
<span
className={cn(
dmSansClassName(),
"text-[14px] font-medium text-[#fafafa]",
)}
>
{isSaving ? "Saving..." : "Save note"}
</span>
<span
className={cn(
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4 ml-1",
)}
>
<svg
className="size-[10px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Command Key</title>
<path
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#737373]",
)}
>
Enter
</span>
</span>
</button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,244 @@
"use client"
import { useState, useCallback } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import {
ChevronLeft,
ChevronRight,
Info,
Loader2,
MessageSquare,
Link2,
} from "lucide-react"
import { Logo } from "@ui/assets/Logo"
export type HighlightFormat = "paragraph" | "bullets" | "quote" | "one_liner"
export interface HighlightItem {
id: string
title: string
content: string
format: HighlightFormat
query: string
sourceDocumentIds: string[]
}
interface HighlightsCardProps {
items: HighlightItem[]
onChat: (seed: string) => void
onShowRelated: (query: string) => void
isLoading?: boolean
width?: number
}
function renderContent(content: string, format: HighlightFormat) {
switch (format) {
case "bullets": {
const lines = content
.split("\n")
.map((line) => line.replace(/^[-•*]\s*/, "").trim())
.filter(Boolean)
return (
<ul className="list-disc pl-[18px] space-y-0">
{lines.map((line, idx) => (
<li key={idx} className="text-[12px] leading-normal">
{line}
</li>
))}
</ul>
)
}
case "quote":
return (
<p className="text-[12px] leading-normal italic border-l-2 border-[#4BA0FA] pl-2">
"{content}"
</p>
)
case "one_liner":
return <p className="text-[12px] leading-normal font-medium">{content}</p>
default:
return <p className="text-[12px] leading-normal">{content}</p>
}
}
export function HighlightsCard({
items,
onChat,
onShowRelated,
isLoading = false,
width = 216,
}: HighlightsCardProps) {
const [activeIndex, setActiveIndex] = useState(0)
const currentItem = items[activeIndex]
const handlePrev = useCallback(() => {
setActiveIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
}, [items.length])
const handleNext = useCallback(() => {
setActiveIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
}, [items.length])
const handleChat = useCallback(() => {
if (!currentItem) return
const seed = `Tell me more about "${currentItem.title}"`
onChat(seed)
}, [currentItem, onChat])
const handleShowRelated = useCallback(() => {
if (!currentItem) return
onShowRelated(currentItem.query || currentItem.title)
}, [currentItem, onShowRelated])
if (isLoading) {
return (
<div
className={cn(
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px] items-center justify-center",
dmSansClassName(),
)}
style={{ width }}
>
<Loader2 className="size-5 animate-spin text-[#4BA0FA]" />
<span className="text-[10px] text-[#737373]">
Loading highlights...
</span>
</div>
)
}
if (!currentItem || items.length === 0) {
return (
<div
className={cn(
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px]",
dmSansClassName(),
)}
style={{ width }}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-1">
<Logo className="size-[14px]" />
<div className="flex items-center gap-0.5">
<span className="text-[10px] text-[#4BA0FA] tracking-[-0.3px]">
powered by
</span>
<span className="text-[10px] text-[#4BA0FA] font-medium tracking-[-0.3px]">
supermemory
</span>
</div>
</div>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-[11px] text-[#737373] text-center">
Add some documents to see highlights here
</p>
</div>
</div>
)
}
return (
<div
className={cn(
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3",
dmSansClassName(),
)}
style={{ width }}
>
<div id="highlights-header" className="flex items-start justify-between">
<div className="flex items-center gap-1">
<Logo className="size-[14px]" />
<div className="flex items-center gap-0.5">
<span className="text-[10px] text-[#4BA0FA] tracking-[-0.3px]">
powered by
</span>
<span className="text-[10px] text-[#4BA0FA] font-medium tracking-[-0.3px]">
supermemory
</span>
</div>
</div>
<Info className="size-[14px] text-[#737373]" />
</div>
<div id="highlights-body" className="flex flex-col gap-1.5">
<p className="text-[12px] font-semibold text-[#FAFAFA] leading-tight truncate">
{currentItem.title}
</p>
<div className="text-[12px] text-[#FAFAFA] leading-normal line-clamp-5">
{renderContent(currentItem.content, currentItem.format)}
</div>
</div>
<div className="flex items-center justify-between w-full gap-2">
<div id="highlights-actions" className="flex gap-2 items-center">
<button
type="button"
onClick={handleChat}
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center gap-1.5 cursor-pointer relative"
style={{
boxShadow: "0 4px 20px 0 rgba(0, 0, 0, 0.25)",
}}
aria-label="Chat with Nova"
>
<MessageSquare className="size-3.5 text-[#FAFAFA]" />
<span className="text-[11px] text-[#FAFAFA]">Chat</span>
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_1px_0_rgba(255,255,255,0.1)]" />
</button>
<button
type="button"
onClick={handleShowRelated}
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center gap-1.5 cursor-pointer relative"
style={{
boxShadow: "0 4px 20px 0 rgba(0, 0, 0, 0.25)",
}}
aria-label="Show related"
>
<Link2 className="size-3.5 text-[#FAFAFA]" />
<span className="text-[11px] text-[#FAFAFA]">Related</span>
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_1px_0_rgba(255,255,255,0.1)]" />
</button>
</div>
{items.length > 1 && (
<div id="highlights-pagination" className="flex items-center gap-2">
<button
type="button"
onClick={handlePrev}
className="text-[#737373] hover:text-white transition-colors cursor-pointer"
aria-label="Previous item"
>
<ChevronLeft className="size-4" />
</button>
<div className="flex items-center gap-1">
{items.map((_, idx) => (
<button
key={idx}
type="button"
onClick={() => setActiveIndex(idx)}
className={cn(
"rounded-full transition-all cursor-pointer",
idx === activeIndex
? "w-4 h-1.5 bg-[#4BA0FA]"
: "size-1.5 bg-[#737373] hover:bg-[#999999]",
)}
aria-label={`Go to item ${idx + 1}`}
/>
))}
</div>
<button
type="button"
onClick={handleNext}
className="text-[#737373] hover:text-white transition-colors cursor-pointer"
aria-label="Next item"
>
<ChevronRight className="size-4" />
</button>
</div>
)}
</div>
</div>
)
}

View file

@ -3,14 +3,13 @@
import { useAuth } from "@lib/auth-context"
import { $fetch } from "@repo/lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react"
import type { z } from "zod"
import { Masonry, useInfiniteLoader } from "masonic"
import { dmSansClassName } from "@/lib/fonts"
import { SuperLoader } from "@/components/superloader"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { useProject } from "@/stores"
import { useIsMobile } from "@hooks/use-mobile"
import type { Tweet } from "react-tweet/api"
@ -24,6 +23,31 @@ import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils"
import { SyncLogoIcon } from "@ui/assets/icons"
import { McpPreview } from "./document-cards/mcp-preview"
import { getFaviconUrl } from "@/lib/url-helpers"
import { QuickNoteCard } from "./quick-note-card"
import { HighlightsCard, type HighlightItem } from "./highlights-card"
import { Button } from "@ui/components/button"
// Document category type
type DocumentCategory =
| "webpage"
| "tweet"
| "google_drive"
| "notion"
| "onedrive"
| "files"
| "notes"
| "mcp"
type DocumentFacet = {
category: DocumentCategory
count: number
label: string
}
type FacetsResponse = {
facets: DocumentFacet[]
total: number
}
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -37,18 +61,65 @@ const IS_DEV = process.env.NODE_ENV === "development"
const PAGE_SIZE = IS_DEV ? 100 : 100
const MAX_TOTAL = 1000
// Discriminated union for masonry items
type MasonryItem =
| { type: "quick-note"; id: string }
| { type: "highlights-card"; id: string }
| { type: "highlights-card-spacer"; id: string }
| { type: "document"; id: string; data: DocumentWithMemories }
interface QuickNoteProps {
onSave: (content: string) => void
onMaximize: (content: string) => void
isSaving: boolean
}
interface HighlightsProps {
items: HighlightItem[]
onChat: (seed: string) => void
onShowRelated: (query: string) => void
isLoading: boolean
}
interface MemoriesGridProps {
isChatOpen: boolean
onOpenDocument: (document: DocumentWithMemories) => void
quickNoteProps?: QuickNoteProps
highlightsProps?: HighlightsProps
}
export function MemoriesGrid({
isChatOpen,
onOpenDocument,
quickNoteProps,
highlightsProps,
}: MemoriesGridProps) {
const { user } = useAuth()
const { selectedProject } = useProject()
const isMobile = useIsMobile()
const [selectedCategories, setSelectedCategories] = useState<
DocumentCategory[]
>([])
const { data: facetsData } = useQuery({
queryKey: ["document-facets", selectedProject],
queryFn: async (): Promise<FacetsResponse> => {
const response = await $fetch("@post/documents/documents/facets", {
body: {
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
})
if (response.error) {
throw new Error(response.error?.message || "Failed to fetch facets")
}
return response.data as FacetsResponse
},
staleTime: 5 * 60 * 1000,
enabled: !!user,
})
const {
data,
@ -58,7 +129,7 @@ export function MemoriesGrid({
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<DocumentsResponse, Error>({
queryKey: ["documents-with-memories", selectedProject],
queryKey: ["documents-with-memories", selectedProject, selectedCategories],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
const response = await $fetch("@post/documents/documents", {
@ -68,6 +139,8 @@ export function MemoriesGrid({
sort: "createdAt",
order: "desc",
containerTags: selectedProject ? [selectedProject] : undefined,
categories:
selectedCategories.length > 0 ? selectedCategories : undefined,
},
disableValidation: true,
})
@ -95,12 +168,58 @@ export function MemoriesGrid({
enabled: !!user,
})
const handleCategoryToggle = useCallback((category: DocumentCategory) => {
setSelectedCategories((prev) => {
if (prev.includes(category)) {
return prev.filter((c) => c !== category)
}
return [...prev, category]
})
}, [])
const handleSelectAll = useCallback(() => {
setSelectedCategories([])
}, [])
const documents = useMemo(() => {
return (
data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? []
)
}, [data])
const hasQuickNote = !!quickNoteProps
const hasHighlights = !!highlightsProps
const masonryItems: MasonryItem[] = useMemo(() => {
const items: MasonryItem[] = []
if (!isMobile) {
if (hasQuickNote) {
items.push({ type: "quick-note", id: "quick-note" })
}
if (hasHighlights) {
items.push({ type: "highlights-card", id: "highlights-card" })
// Add spacer to occupy the second column space for the 2-column highlights card
items.push({
type: "highlights-card-spacer",
id: "highlights-card-spacer",
})
}
}
for (const doc of documents) {
items.push({ type: "document", id: doc.id, data: doc })
}
return items
}, [documents, isMobile, hasQuickNote, hasHighlights])
// Stable key for Masonry based on document IDs, not item values
const masonryKey = useMemo(() => {
const docIds = documents.map((d) => d.id).join(",")
return `masonry-${documents.length}-${docIds}-${isChatOpen}-${hasQuickNote}-${hasHighlights}`
}, [documents, isChatOpen, hasQuickNote, hasHighlights])
const isLoadingMore = isFetchingNextPage
const loadMoreDocuments = useCallback(async (): Promise<void> => {
@ -131,24 +250,61 @@ export function MemoriesGrid({
[onOpenDocument],
)
const renderDocumentCard = useCallback(
const renderMasonryItem = useCallback(
({
index,
data,
width,
}: {
index: number
data: DocumentWithMemories
data: MasonryItem
width: number
}) => (
<DocumentCard
index={index}
data={data}
width={width}
onClick={handleCardClick}
/>
),
[handleCardClick],
}) => {
if (data.type === "quick-note" && quickNoteProps) {
return (
<div className="p-2" style={{ width }}>
<QuickNoteCard {...quickNoteProps} />
</div>
)
}
if (data.type === "highlights-card" && highlightsProps) {
const doubleWidth = width * 2
const cardWidth = doubleWidth - 16
return (
<div className="p-2" style={{ width: doubleWidth }}>
<HighlightsCard {...highlightsProps} width={cardWidth} />
</div>
)
}
if (data.type === "highlights-card-spacer") {
return (
<div
style={{
width,
height: 220, // Approximate height of HighlightsCard
visibility: "hidden",
pointerEvents: "none",
}}
/>
)
}
if (data.type === "document") {
return (
<DocumentCard
index={index}
data={data.data}
width={width}
onClick={handleCardClick}
/>
)
}
return null
},
[handleCardClick, quickNoteProps, highlightsProps],
)
if (!user) {
@ -163,15 +319,37 @@ export function MemoriesGrid({
return (
<div className="relative">
<Button
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-4 py-2 data-[state=active]:bg-[#00173C] data-[state=active]:border-[#2261CA33] mb-4",
)}
data-state="active"
>
All
</Button>
<div id="filter-pills" className="flex flex-wrap gap-1.5 mb-3">
<Button
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
selectedCategories.length === 0 &&
"bg-[#00173C] border-[#2261CA33]",
)}
onClick={handleSelectAll}
>
All
{facetsData?.total !== undefined && (
<span className="ml-1 text-[#737373]">({facetsData.total})</span>
)}
</Button>
{facetsData?.facets.map((facet: DocumentFacet) => (
<Button
key={facet.category}
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
selectedCategories.includes(facet.category) &&
"bg-[#00173C] border-[#2261CA33]",
)}
onClick={() => handleCategoryToggle(facet.category)}
>
{facet.label}
<span className="ml-1 text-[#737373]">({facet.count})</span>
</Button>
))}
</div>
{error ? (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
@ -191,9 +369,9 @@ export function MemoriesGrid({
) : (
<div className="h-full overflow-auto scrollbar-thin">
<Masonry
key={`masonry-${documents.length}-${documents.map((d) => d.id).join(",")}-${isChatOpen}`}
items={documents}
render={renderDocumentCard}
key={masonryKey}
items={masonryItems}
render={renderMasonryItem}
columnGutter={0}
rowGutter={0}
columnWidth={216}

View file

@ -0,0 +1,159 @@
"use client"
import { useRef, useCallback } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { Maximize2, Plus, Loader2 } from "lucide-react"
import { useProject } from "@/stores"
import { useQuickNoteDraft } from "@/stores/quick-note-draft"
interface QuickNoteCardProps {
onSave: (content: string) => void
onMaximize: (content: string) => void
isSaving?: boolean
}
export function QuickNoteCard({
onSave,
onMaximize,
isSaving = false,
}: QuickNoteCardProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const { selectedProject } = useProject()
const { draft, setDraft } = useQuickNoteDraft(selectedProject)
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDraft(e.target.value)
},
[setDraft],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault()
if (draft.trim() && !isSaving) {
onSave(draft)
}
}
},
[draft, isSaving, onSave],
)
const handleSaveClick = useCallback(() => {
if (draft.trim() && !isSaving) {
onSave(draft)
}
}, [draft, isSaving, onSave])
const handleMaximizeClick = useCallback(() => {
onMaximize(draft)
}, [draft, onMaximize])
const canSave = draft.trim().length > 0 && !isSaving
return (
<div
className="bg-[#1B1F24] rounded-[22px] p-1"
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<div
id="quick-note-inner"
className="bg-[#0B1017] rounded-[18px] p-3 relative"
style={{
boxShadow: "inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
}}
>
<button
type="button"
onClick={handleMaximizeClick}
className="absolute top-3 right-3 text-[#737373] hover:text-white transition-colors cursor-pointer"
aria-label="Expand to full screen"
>
<Maximize2 className="size-[14px]" />
</button>
<textarea
ref={textareaRef}
value={draft}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Start writing..."
disabled={isSaving}
className={cn(
dmSansClassName(),
"w-full h-[120px] bg-transparent resize-none outline-none text-[12px] leading-normal text-white placeholder:text-[#737373] pr-5 disabled:opacity-50",
)}
/>
<div
id="quick-note-action-bar"
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center justify-center gap-8 w-full"
style={{
boxShadow:
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
}}
>
<button
type="button"
onClick={handleSaveClick}
disabled={!canSave}
className={cn(
"flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<span className="flex items-center gap-1.5">
{isSaving ? (
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
) : (
<Plus className="size-2 text-[#fafafa]" />
)}
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#fafafa]",
)}
>
{isSaving ? "Saving..." : "Save note"}
</span>
</span>
<span
className={cn(
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4",
)}
>
<svg
className="size-[10px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Command Key</title>
<path
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#737373]",
)}
>
Enter
</span>
</span>
</button>
</div>
</div>
</div>
)
}

View file

@ -54,6 +54,7 @@ export function TextEditor({
handleKeyDown: (_view, event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault()
debouncedUpdates.flush()
onSubmitRef.current?.()
return true
}
@ -104,9 +105,11 @@ export function TextEditor({
useEffect(() => {
return () => {
// Flush any pending debounced updates before destroying editor
debouncedUpdates.flush()
editor?.destroy()
}
}, [editor])
}, [editor, debouncedUpdates])
return (
<>

View file

@ -44,17 +44,16 @@ export function SuperLoader({
const animateVariant = prefersReducedMotion ? "static" : "visible"
return (
<div
role="status"
<output
aria-label={label}
className={`inline-flex flex-col items-center gap-2 ${className}`}
className={`inline-flex flex-row items-center gap-2 ${className}`}
style={{ width: size + 10 }}
>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 21 21"
width={size}
height={size}
width={size * 0.5}
height={size * 0.5}
className={`shrink-0 ${colorClassName}`}
>
<title>Loading...</title>
@ -93,6 +92,6 @@ export function SuperLoader({
>
{label}
</span>
</div>
</output>
)
}

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)
}

View file

@ -46,7 +46,17 @@
.tweet-body-module__ZNRZja__root > * {
font-size: 10px !important;
line-height: 0.25rem !important;
line-height: 1.35 !important;
letter-spacing: -0.1px !important;
white-space: pre-wrap !important;
}
.sm-tweet-theme .tweet-body-module__ZNRZja__root {
font-family: "DM Sans", sans-serif !important;
font-weight: 500 !important;
color: #fafafa !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
[class*="dmSans"],

View file

@ -0,0 +1,54 @@
import { create } from "zustand"
interface QuickNoteDraftState {
draftByProject: Record<string, string>
setDraft: (projectId: string, draft: string) => void
resetDraft: (projectId: string) => void
}
export const useQuickNoteDraftStore = create<QuickNoteDraftState>()(
(set, get) => ({
draftByProject: {},
setDraft: (projectId, draft) => {
const current = get().draftByProject[projectId]
if (current === draft) return
set((state) => ({
draftByProject: {
...state.draftByProject,
[projectId]: draft,
},
}))
},
resetDraft: (projectId) => {
const current = get().draftByProject[projectId]
if (current === undefined || current === "") return
set((state) => ({
draftByProject: {
...state.draftByProject,
[projectId]: "",
},
}))
},
}),
)
export function useQuickNoteDraft(projectId: string) {
const draft = useQuickNoteDraftStore(
(s) => s.draftByProject[projectId] ?? "",
)
const setDraft = useQuickNoteDraftStore((s) => s.setDraft)
const resetDraft = useQuickNoteDraftStore((s) => s.resetDraft)
return {
draft,
setDraft: (value: string) => setDraft(projectId, value),
resetDraft: () => resetDraft(projectId),
}
}
export function useQuickNoteDraftReset(projectId: string) {
const resetDraft = useQuickNoteDraftStore((s) => s.resetDraft)
return () => resetDraft(projectId)
}

View file

@ -414,12 +414,45 @@ interface SupermemoryToolsConfig {
baseUrl?: string
containerTags?: string[]
projectId?: string
strict?: boolean
}
```
- **baseUrl**: Custom base URL for the supermemory API
- **containerTags**: Array of custom container tags (mutually exclusive with projectId)
- **projectId**: Project ID which gets converted to container tag format (mutually exclusive with containerTags)
- **strict**: Enable strict schema mode for OpenAI strict validation. When `true`, all schema properties are required (satisfies OpenAI strict mode). When `false` (default), optional fields remain optional for maximum compatibility with all models.
### OpenAI Strict Mode Compatibility
When using OpenAI-compatible providers with strict schema validation (e.g., OpenRouter with Azure OpenAI backend), enable strict mode to ensure all schema properties are included in the `required` array:
```typescript
import { searchMemoriesTool, addMemoryTool } from "@supermemory/tools/ai-sdk"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { streamText } from "ai"
const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY })
const tools = {
searchMemories: searchMemoriesTool(apiKey, {
containerTags: [userId],
strict: true // ✅ Required for OpenAI strict mode
}),
addMemory: addMemoryTool(apiKey, {
containerTags: [userId],
strict: true
}),
}
const result = streamText({
model: openrouter.chat("openai/gpt-5-nano"),
messages: [...],
tools,
})
```
Without `strict: true`, optional fields like `includeFullDocs` and `limit` won't be in the `required` array, which will cause validation errors with OpenAI strict mode.
### withSupermemory Middleware Options

View file

@ -20,6 +20,7 @@ export const searchMemoriesTool = (
})
const containerTags = getContainerTags(config)
const strict = config?.strict ?? false
return tool({
description: TOOL_DESCRIPTIONS.searchMemories,
@ -27,16 +28,26 @@ export const searchMemoriesTool = (
informationToGet: z
.string()
.describe(PARAMETER_DESCRIPTIONS.informationToGet),
includeFullDocs: z
.boolean()
.optional()
.default(DEFAULT_VALUES.includeFullDocs)
.describe(PARAMETER_DESCRIPTIONS.includeFullDocs),
limit: z
.number()
.optional()
.default(DEFAULT_VALUES.limit)
.describe(PARAMETER_DESCRIPTIONS.limit),
includeFullDocs: strict
? z
.boolean()
.default(DEFAULT_VALUES.includeFullDocs)
.describe(PARAMETER_DESCRIPTIONS.includeFullDocs)
: z
.boolean()
.optional()
.default(DEFAULT_VALUES.includeFullDocs)
.describe(PARAMETER_DESCRIPTIONS.includeFullDocs),
limit: strict
? z
.number()
.default(DEFAULT_VALUES.limit)
.describe(PARAMETER_DESCRIPTIONS.limit)
: z
.number()
.optional()
.default(DEFAULT_VALUES.limit)
.describe(PARAMETER_DESCRIPTIONS.limit),
}),
execute: async ({
informationToGet,

View file

@ -3,7 +3,22 @@
* Only one of `projectId` or `containerTags` can be provided.
*/
export interface SupermemoryToolsConfig {
/**
* Custom base URL for the supermemory API
*/
baseUrl?: string
/**
* Array of custom container tags (mutually exclusive with projectId)
*/
containerTags?: string[]
/**
* Project ID which gets converted to container tag format (mutually exclusive with containerTags)
*/
projectId?: string
/**
* Enable strict schema mode for OpenAI strict validation.
* When true, all schema properties are required (satisfies OpenAI strict mode).
* When false (default), optional fields remain optional for maximum compatibility.
*/
strict?: boolean
}