diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts index cadfa734..ce88be33 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -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, diff --git a/apps/web/app/(navigation)/layout.tsx b/apps/web/app/(navigation)/layout.tsx index 7b7628bb..68a67a93 100644 --- a/apps/web/app/(navigation)/layout.tsx +++ b/apps/web/app/(navigation)/layout.tsx @@ -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 diff --git a/apps/web/app/api/og/route.ts b/apps/web/app/api/og/route.ts index 97f024a5..4c61ebe5 100644 --- a/apps/web/app/api/og/route.ts +++ b/apps/web/app/api/og/route.ts @@ -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) diff --git a/apps/web/app/new/page.tsx b/apps/web/app/new/page.tsx index afb077b3..31fab182 100644 --- a/apps/web/app/new/page.tsx +++ b/apps/web/app/new/page.tsx @@ -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(null) const [isDocumentModalOpen, setIsDocumentModalOpen] = useState(false) + const [isFullScreenNoteOpen, setIsFullScreenNoteOpen] = useState(false) + const [fullscreenInitialContent, setFullscreenInitialContent] = useState("") + const [queuedChatSeed, setQueuedChatSeed] = useState(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({ + queryKey: ["space-highlights", selectedProject], + queryFn: async (): Promise => { + 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 (
@@ -69,10 +160,21 @@ export default function NewPage() { key={`main-container-${isChatOpen}`} className="z-10 flex flex-col md:flex-row relative" > -
+
@@ -80,13 +182,22 @@ export default function NewPage() { setQueuedChatSeed(null)} + emptyStateSuggestions={highlightsData?.questions} />
{isMobile && ( - + setQueuedChatSeed(null)} + emptyStateSuggestions={highlightsData?.questions} + /> )} { + setIsSearchOpen(open) + if (!open) setSearchPrefill("") + }} projectId={selectedProject} onOpenDocument={handleOpenDocument} + initialSearch={searchPrefill} /> setIsDocumentModalOpen(false)} /> + setIsFullScreenNoteOpen(false)} + initialContent={fullscreenInitialContent} + onSave={handleFullScreenSave} + isSaving={noteMutation.isPending} + />
) 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 435667b0..22a77812 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,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 (
onSuggestionClick(suggestion)} > - - {suggestion} + + + {suggestion} + ))}
@@ -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( + 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(() => { @@ -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({ />
{!isMobile && ( - + + + + + + Chat History + + Project: {selectedProject} + + + + {isLoadingThreads ? ( +
+ +
+ ) : threads.length === 0 ? ( +
+ No conversations yet +
+ ) : ( +
+ {threads.map((thread) => { + const isActive = thread.id === currentChatId + return ( + + +
+ ) : ( + + )} + + ) + })} +
+ )} + + + + )} +
+ + +
+
+
+ +
+
+
+ +
+ +
+ + + ) +} diff --git a/apps/web/components/new/highlights-card.tsx b/apps/web/components/new/highlights-card.tsx new file mode 100644 index 00000000..eb3e0b44 --- /dev/null +++ b/apps/web/components/new/highlights-card.tsx @@ -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 ( +
    + {lines.map((line, idx) => ( +
  • + {line} +
  • + ))} +
+ ) + } + case "quote": + return ( +

+ "{content}" +

+ ) + case "one_liner": + return

{content}

+ default: + return

{content}

+ } +} + +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 ( +
+ + + Loading highlights... + +
+ ) + } + + if (!currentItem || items.length === 0) { + return ( +
+
+
+ +
+ + powered by + + + supermemory + +
+
+
+
+

+ Add some documents to see highlights here +

+
+
+ ) + } + + return ( +
+
+
+ +
+ + powered by + + + supermemory + +
+
+ +
+ +
+

+ {currentItem.title} +

+
+ {renderContent(currentItem.content, currentItem.format)} +
+
+ +
+
+ + +
+ + {items.length > 1 && ( +
+ +
+ {items.map((_, idx) => ( +
+ +
+ )} +
+
+ ) +} diff --git a/apps/web/components/new/memories-grid.tsx b/apps/web/components/new/memories-grid.tsx index a28d7934..e9dac883 100644 --- a/apps/web/components/new/memories-grid.tsx +++ b/apps/web/components/new/memories-grid.tsx @@ -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 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 => { + 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({ - 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 => { @@ -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 - }) => ( - - ), - [handleCardClick], + }) => { + if (data.type === "quick-note" && quickNoteProps) { + return ( +
+ +
+ ) + } + + if (data.type === "highlights-card" && highlightsProps) { + const doubleWidth = width * 2 + const cardWidth = doubleWidth - 16 + return ( +
+ +
+ ) + } + + if (data.type === "highlights-card-spacer") { + return ( +
+ ) + } + + if (data.type === "document") { + return ( + + ) + } + + return null + }, + [handleCardClick, quickNoteProps, highlightsProps], ) if (!user) { @@ -163,15 +319,37 @@ export function MemoriesGrid({ return (
- +
+ + {facetsData?.facets.map((facet: DocumentFacet) => ( + + ))} +
{error ? (
@@ -191,9 +369,9 @@ export function MemoriesGrid({ ) : (
d.id).join(",")}-${isChatOpen}`} - items={documents} - render={renderDocumentCard} + key={masonryKey} + items={masonryItems} + render={renderMasonryItem} columnGutter={0} rowGutter={0} columnWidth={216} diff --git a/apps/web/components/new/quick-note-card.tsx b/apps/web/components/new/quick-note-card.tsx new file mode 100644 index 00000000..224f77d0 --- /dev/null +++ b/apps/web/components/new/quick-note-card.tsx @@ -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(null) + const { selectedProject } = useProject() + const { draft, setDraft } = useQuickNoteDraft(selectedProject) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + setDraft(e.target.value) + }, + [setDraft], + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + 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 ( +
+
+ + +