aboutsummaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorDhravya Shah <[email protected]>2026-01-23 17:42:47 -0700
committerDhravya Shah <[email protected]>2026-01-23 17:42:47 -0700
commit4ca0f593a5d89695e101569f09debda5617c0ec6 (patch)
tree60517a8e898965cf8120cc01c56f69baaff0d06e /apps
parentextract metadata ourselves (diff)
parentfix: cf build (#700) (diff)
downloadsupermemory-4ca0f593a5d89695e101569f09debda5617c0ec6.tar.xz
supermemory-4ca0f593a5d89695e101569f09debda5617c0ec6.zip
fix: merge conflicts
Diffstat (limited to 'apps')
-rw-r--r--apps/mcp/src/client.ts2
-rw-r--r--apps/web/app/(navigation)/layout.tsx12
-rw-r--r--apps/web/app/api/og/route.ts77
-rw-r--r--apps/web/app/new/page.tsx128
-rw-r--r--apps/web/components/header.tsx5
-rw-r--r--apps/web/components/new/chat/index.tsx350
-rw-r--r--apps/web/components/new/chat/message/agent-message.tsx2
-rw-r--r--apps/web/components/new/document-cards/tweet-preview.tsx145
-rw-r--r--apps/web/components/new/documents-command-palette.tsx6
-rw-r--r--apps/web/components/new/fullscreen-note-modal.tsx223
-rw-r--r--apps/web/components/new/highlights-card.tsx244
-rw-r--r--apps/web/components/new/memories-grid.tsx230
-rw-r--r--apps/web/components/new/quick-note-card.tsx159
-rw-r--r--apps/web/components/new/text-editor/index.tsx5
-rw-r--r--apps/web/components/superloader.tsx11
-rw-r--r--apps/web/components/views/chat/index.tsx3
-rw-r--r--apps/web/globals.css12
-rw-r--r--apps/web/stores/quick-note-draft.ts54
18 files changed, 1580 insertions, 88 deletions
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<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>
)
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 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 (
<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
diff --git a/apps/web/components/new/chat/message/agent-message.tsx b/apps/web/components/new/chat/message/agent-message.tsx
index bf9f0eb0..f4528ef5 100644
--- a/apps/web/components/new/chat/message/agent-message.tsx
+++ b/apps/web/components/new/chat/message/agent-message.tsx
@@ -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>
diff --git a/apps/web/components/new/document-cards/tweet-preview.tsx b/apps/web/components/new/document-cards/tweet-preview.tsx
index 291bc18a..2d9baacf 100644
--- a/apps/web/components/new/document-cards/tweet-preview.tsx
+++ b/apps/web/components/new/document-cards/tweet-preview.tsx
@@ -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>
)
diff --git a/apps/web/components/new/documents-command-palette.tsx b/apps/web/components/new/documents-command-palette.tsx
index 48cafc74..c21b4cf3 100644
--- a/apps/web/components/new/documents-command-palette.tsx
+++ b/apps/web/components/new/documents-command-palette.tsx
@@ -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
diff --git a/apps/web/components/new/fullscreen-note-modal.tsx b/apps/web/components/new/fullscreen-note-modal.tsx
new file mode 100644
index 00000000..8ac90ee1
--- /dev/null
+++ b/apps/web/components/new/fullscreen-note-modal.tsx
@@ -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>
+ )
+}
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 (
+ <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>
+ )
+}
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<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}
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<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>
+ )
+}
diff --git a/apps/web/components/new/text-editor/index.tsx b/apps/web/components/new/text-editor/index.tsx
index d99a0aea..18de5893 100644
--- a/apps/web/components/new/text-editor/index.tsx
+++ b/apps/web/components/new/text-editor/index.tsx
@@ -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 (
<>
diff --git a/apps/web/components/superloader.tsx b/apps/web/components/superloader.tsx
index 1e51df68..e2f1478a 100644
--- a/apps/web/components/superloader.tsx
+++ b/apps/web/components/superloader.tsx
@@ -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>
)
}
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)
}
diff --git a/apps/web/globals.css b/apps/web/globals.css
index c9e5da27..647a85de 100644
--- a/apps/web/globals.css
+++ b/apps/web/globals.css
@@ -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"],
diff --git a/apps/web/stores/quick-note-draft.ts b/apps/web/stores/quick-note-draft.ts
new file mode 100644
index 00000000..afd2bc6d
--- /dev/null
+++ b/apps/web/stores/quick-note-draft.ts
@@ -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)
+}