aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/new/page.tsx42
-rw-r--r--apps/web/components/new/chat/index.tsx18
-rw-r--r--apps/web/components/new/document-cards/file-preview.tsx102
-rw-r--r--apps/web/components/new/document-cards/google-docs-preview.tsx51
-rw-r--r--apps/web/components/new/document-cards/note-preview.tsx130
-rw-r--r--apps/web/components/new/document-cards/website-preview.tsx33
-rw-r--r--apps/web/components/new/document-icon.tsx322
-rw-r--r--apps/web/components/new/document-modal/content/google-doc.tsx66
-rw-r--r--apps/web/components/new/document-modal/content/image-preview.tsx56
-rw-r--r--apps/web/components/new/document-modal/content/index.tsx143
-rw-r--r--apps/web/components/new/document-modal/content/text-editor-content.tsx91
-rw-r--r--apps/web/components/new/document-modal/document-icon.tsx273
-rw-r--r--apps/web/components/new/document-modal/graph-list-memories.tsx36
-rw-r--r--apps/web/components/new/document-modal/index.tsx129
-rw-r--r--apps/web/components/new/document-modal/title.tsx19
-rw-r--r--apps/web/components/new/documents-command-palette.tsx233
-rw-r--r--apps/web/components/new/header.tsx11
-rw-r--r--apps/web/components/new/mcp-modal/index.tsx6
-rw-r--r--apps/web/components/new/mcp-modal/mcp-detail-view.tsx2
-rw-r--r--apps/web/components/new/memories-grid.tsx116
-rw-r--r--apps/web/lib/url-helpers.ts46
-rw-r--r--packages/ui/components/sonner.tsx5
22 files changed, 1230 insertions, 700 deletions
diff --git a/apps/web/app/new/page.tsx b/apps/web/app/new/page.tsx
index 2417ccb7..afb077b3 100644
--- a/apps/web/app/new/page.tsx
+++ b/apps/web/app/new/page.tsx
@@ -1,28 +1,51 @@
"use client"
-import { useState } from "react"
+import { useState, useCallback } from "react"
import { Header } from "@/components/new/header"
import { ChatSidebar } from "@/components/new/chat"
import { MemoriesGrid } from "@/components/new/memories-grid"
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
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 { 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 { analytics } from "@/lib/analytics"
+import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
+import type { z } from "zod"
+
+type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
+type DocumentWithMemories = DocumentsResponse["documents"][0]
export default function NewPage() {
const isMobile = useIsMobile()
+ const { selectedProject } = useProject()
const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false)
const [isMCPModalOpen, setIsMCPModalOpen] = useState(false)
+ const [isSearchOpen, setIsSearchOpen] = useState(false)
+ const [selectedDocument, setSelectedDocument] =
+ useState<DocumentWithMemories | null>(null)
+ const [isDocumentModalOpen, setIsDocumentModalOpen] = useState(false)
+
useHotkeys("c", () => {
analytics.addDocumentModalOpened()
setIsAddDocumentOpen(true)
})
+ useHotkeys("mod+k", (e) => {
+ e.preventDefault()
+ setIsSearchOpen(true)
+ })
const [isChatOpen, setIsChatOpen] = useState(!isMobile)
+ const handleOpenDocument = useCallback((document: DocumentWithMemories) => {
+ setSelectedDocument(document)
+ setIsDocumentModalOpen(true)
+ }, [])
+
return (
<HotkeysProvider>
<div className="bg-black min-h-screen">
@@ -40,13 +63,17 @@ export default function NewPage() {
setIsMCPModalOpen(true)
}}
onOpenChat={() => setIsChatOpen(true)}
+ onOpenSearch={() => setIsSearchOpen(true)}
/>
<main
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">
- <MemoriesGrid isChatOpen={isChatOpen} />
+ <MemoriesGrid
+ isChatOpen={isChatOpen}
+ onOpenDocument={handleOpenDocument}
+ />
</div>
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
<AnimatePresence mode="popLayout">
@@ -70,6 +97,17 @@ export default function NewPage() {
isOpen={isMCPModalOpen}
onClose={() => setIsMCPModalOpen(false)}
/>
+ <DocumentsCommandPalette
+ open={isSearchOpen}
+ onOpenChange={setIsSearchOpen}
+ projectId={selectedProject}
+ onOpenDocument={handleOpenDocument}
+ />
+ <DocumentModal
+ document={selectedDocument}
+ isOpen={isDocumentModalOpen}
+ onClose={() => setIsDocumentModalOpen(false)}
+ />
</div>
</HotkeysProvider>
)
diff --git a/apps/web/components/new/chat/index.tsx b/apps/web/components/new/chat/index.tsx
index 8c57aeae..32fe116e 100644
--- a/apps/web/components/new/chat/index.tsx
+++ b/apps/web/components/new/chat/index.tsx
@@ -383,7 +383,7 @@ export function ChatSidebar({
className={cn(
"flex items-start justify-start",
isMobile
- ? "fixed bottom-4 right-4 z-50"
+ ? "fixed bottom-5 right-0 left-0 z-50 justify-center items-center"
: "absolute top-0 right-0 m-4",
dmSansClassName(),
)}
@@ -392,15 +392,21 @@ export function ChatSidebar({
<motion.button
onClick={toggleChat}
className={cn(
- "flex items-center gap-3 rounded-full px-3 py-1.5 text-sm font-medium border border-[#17181A] text-white cursor-pointer whitespace-nowrap shadow-lg",
- isMobile && "px-4 py-2",
+ "flex items-center gap-3 rounded-full px-3 py-1.5 text-sm font-medium border text-white cursor-pointer whitespace-nowrap",
+ isMobile
+ ? "gap-2.5 px-5 py-3 text-[15px] border-[#1E2128] shadow-[0_8px_32px_rgba(0,0,0,0.5),0_2px_8px_rgba(0,0,0,0.3)]"
+ : "border-[#17181A] shadow-lg",
)}
style={{
- background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
+ background: isMobile
+ ? "linear-gradient(135deg, #12161C 0%, #0A0D12 100%)"
+ : "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
+ whileHover={{ scale: 1.02 }}
+ whileTap={{ scale: 0.98 }}
>
- <NovaOrb size={24} className="blur-[0.6px]! z-10" />
- {!isMobile && "Chat with Nova"}
+ <NovaOrb size={isMobile ? 26 : 24} className="blur-[0.6px]! z-10" />
+ <span className={cn(isMobile && "font-medium")}>Chat with Nova</span>
</motion.button>
</motion.div>
) : (
diff --git a/apps/web/components/new/document-cards/file-preview.tsx b/apps/web/components/new/document-cards/file-preview.tsx
index 93e53463..f30645dc 100644
--- a/apps/web/components/new/document-cards/file-preview.tsx
+++ b/apps/web/components/new/document-cards/file-preview.tsx
@@ -5,71 +5,12 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
-import { PDF } from "@ui/assets/icons"
-import { FileText, Image, Video } from "lucide-react"
+import { DocumentIcon } from "@/components/new/document-icon"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
-function PDFIcon() {
- return (
- <svg
- width="8"
- height="10"
- viewBox="0 0 8 10"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
- <title>PDF Icon</title>
- <g filter="url(#filter0_i_719_6586)">
- <path
- d="M1 10C0.725 10 0.489583 9.90208 0.29375 9.70625C0.0979167 9.51042 0 9.275 0 9V1C0 0.725 0.0979167 0.489583 0.29375 0.29375C0.489583 0.0979167 0.725 0 1 0H5L8 3V9C8 9.275 7.90208 9.51042 7.70625 9.70625C7.51042 9.90208 7.275 10 7 10H1ZM4.5 3.5V1H1V9H7V3.5H4.5Z"
- fill="#FF7673"
- />
- </g>
- <defs>
- <filter
- id="filter0_i_719_6586"
- x="0"
- y="0"
- width="8.25216"
- height="10.2522"
- filterUnits="userSpaceOnUse"
- color-interpolation-filters="sRGB"
- >
- <feFlood flood-opacity="0" result="BackgroundImageFix" />
- <feBlend
- mode="normal"
- in="SourceGraphic"
- in2="BackgroundImageFix"
- result="shape"
- />
- <feColorMatrix
- in="SourceAlpha"
- type="matrix"
- values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
- result="hardAlpha"
- />
- <feOffset dx="0.252163" dy="0.252163" />
- <feGaussianBlur stdDeviation="0.504325" />
- <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
- <feColorMatrix
- type="matrix"
- values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
- />
- <feBlend
- mode="normal"
- in2="shape"
- result="effect1_innerShadow_719_6586"
- />
- </filter>
- </defs>
- </svg>
- )
-}
-
function getFileTypeInfo(document: DocumentWithMemories): {
- icon: React.ReactNode
extension: string
color?: string
} {
@@ -78,56 +19,33 @@ function getFileTypeInfo(document: DocumentWithMemories): {
if (mimeType) {
if (mimeType === "application/pdf") {
- return {
- icon: <PDFIcon />,
- extension: ".pdf",
- color: "#FF7673",
- }
+ return { extension: ".pdf", color: "#FF7673" }
}
if (mimeType.startsWith("image/")) {
const ext = mimeType.split("/")[1] || "jpg"
- return {
- icon: <Image className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
- extension: `.${ext}`,
- }
+ return { extension: `.${ext}` }
}
if (mimeType.startsWith("video/")) {
const ext = mimeType.split("/")[1] || "mp4"
- return {
- icon: <Video className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
- extension: `.${ext}`,
- }
+ return { extension: `.${ext}` }
}
}
switch (type) {
case "pdf":
- return {
- icon: <PDF className="w-4 h-4 text-[#FF7673]" />,
- extension: ".pdf",
- color: "#FF7673",
- }
+ return { extension: ".pdf", color: "#FF7673" }
case "image":
- return {
- icon: <Image className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
- extension: ".jpg",
- }
+ return { extension: ".jpg" }
case "video":
- return {
- icon: <Video className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
- extension: ".mp4",
- }
+ return { extension: ".mp4" }
default:
- return {
- icon: <FileText className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
- extension: ".file",
- }
+ return { extension: ".file" }
}
}
export function FilePreview({ document }: { document: DocumentWithMemories }) {
const [imageError, setImageError] = useState(false)
- const { icon, extension, color } = getFileTypeInfo(document)
+ const { extension, color } = getFileTypeInfo(document)
const type = document.type?.toLowerCase()
const mimeType = document.metadata?.mimeType as string | undefined
@@ -168,7 +86,7 @@ export function FilePreview({ document }: { document: DocumentWithMemories }) {
) : (
<div className="p-3">
<div className="flex items-center gap-1 mb-2">
- {icon}
+ <DocumentIcon type={document.type} url={document.url} className="w-4 h-4" />
<p
className={cn(dmSansClassName(), "text-[10px] font-semibold")}
style={{ color: color }}
diff --git a/apps/web/components/new/document-cards/google-docs-preview.tsx b/apps/web/components/new/document-cards/google-docs-preview.tsx
index 6c783c7d..50a56a16 100644
--- a/apps/web/components/new/document-cards/google-docs-preview.tsx
+++ b/apps/web/components/new/document-cards/google-docs-preview.tsx
@@ -4,6 +4,10 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
+import {
+ DocumentIcon,
+ getDocumentTypeLabel,
+} from "@/components/new/document-icon"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@@ -13,49 +17,28 @@ export function GoogleDocsPreview({
}: {
document: DocumentWithMemories
}) {
+ const label = getDocumentTypeLabel(document.type)
+
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] gap-3">
<div className="flex items-center gap-2 mb-2">
- <svg
- className="w-4 h-4"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 87.3 78"
- aria-label="Google Docs"
- >
- <title>Google Docs</title>
- <path
- fill="#0066da"
- d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3L27.5 53H0c0 1.55.4 3.1 1.2 4.5z"
- />
- <path
- fill="#00ac47"
- d="M43.65 25 29.9 1.2c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44A9.06 9.06 0 0 0 0 53h27.5z"
- />
- <path
- fill="#ea4335"
- d="M73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75L86.1 57.5c.8-1.4 1.2-2.95 1.2-4.5H59.798l5.852 11.5z"
- />
- <path
- fill="#00832d"
- d="M43.65 25 57.4 1.2C56.05.4 54.5 0 52.9 0H34.4c-1.6 0-3.15.45-4.5 1.2z"
- />
- <path
- fill="#2684fc"
- d="M59.8 53H27.5L13.75 76.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
- />
- <path
- fill="#ffba00"
- d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3L43.65 25 59.8 53h27.45c0-1.55-.4-3.1-1.2-4.5z"
- />
- </svg>
+ <DocumentIcon type={document.type} url={document.url} className="w-4 h-4" />
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
- Google Docs
+ {label}
</p>
</div>
- {document.content && (
+ {document.summary ? (
+ <p className="text-[10px] text-[#737373] line-clamp-4">
+ {document.summary}
+ </p>
+ ) : document.content ? (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
+ ) : (
+ <p className="text-[10px] text-[#737373] line-clamp-4">
+ No summary available
+ </p>
)}
</div>
)
diff --git a/apps/web/components/new/document-cards/note-preview.tsx b/apps/web/components/new/document-cards/note-preview.tsx
index c2b767b0..3e684a88 100644
--- a/apps/web/components/new/document-cards/note-preview.tsx
+++ b/apps/web/components/new/document-cards/note-preview.tsx
@@ -4,138 +4,16 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
-import { useMemo } from "react"
+import { DocumentIcon } from "@/components/new/document-icon"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
-type TipTapNode = {
- type: string
- text?: string
- content?: TipTapNode[]
- attrs?: Record<string, unknown>
-}
-
-function extractTextFromTipTapContent(content: string): string {
- try {
- const json = JSON.parse(content) as TipTapNode
- return extractTextFromNode(json)
- } catch {
- return content
- }
-}
-
-function extractTextFromNode(node: TipTapNode): string {
- if (node.type === "text" && node.text) {
- return node.text
- }
-
- if (!node.content) {
- return ""
- }
-
- const texts: string[] = []
- for (const child of node.content) {
- const text = extractTextFromNode(child)
- if (text) {
- texts.push(text)
- }
- }
-
- const blockTypes = [
- "paragraph",
- "heading",
- "listItem",
- "blockquote",
- "codeBlock",
- ]
- if (blockTypes.includes(node.type)) {
- return `${texts.join("")}\n`
- }
-
- return texts.join("")
-}
-
-function NoteIcon() {
- return (
- <svg
- width="14"
- height="14"
- viewBox="0 0 14 14"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- >
- <title>Note Icon</title>
- <mask
- id="mask0_344_4970"
- style={{ maskType: "alpha" }}
- maskUnits="userSpaceOnUse"
- x="0"
- y="0"
- width="14"
- height="14"
- >
- <rect width="14" height="14" fill="#D9D9D9" />
- </mask>
- <g mask="url(#mask0_344_4970)">
- <g filter="url(#filter0_i_344_4970)">
- <path
- d="M3.50002 8.75008H7.58335L8.75002 7.58341H3.50002V8.75008ZM3.50002 6.41675H7.00002V5.25008H3.50002V6.41675ZM2.33335 4.08341V9.91675H6.41669L5.25002 11.0834H1.16669V2.91675H12.8334V4.66675H11.6667V4.08341H2.33335ZM13.3584 7.17508C13.407 7.22369 13.4313 7.27716 13.4313 7.3355C13.4313 7.39383 13.407 7.4473 13.3584 7.49591L12.8334 8.02091L11.8125 7.00008L12.3375 6.47508C12.3861 6.42647 12.4396 6.40216 12.4979 6.40216C12.5563 6.40216 12.6097 6.42647 12.6584 6.47508L13.3584 7.17508ZM7.58335 12.2501V11.2292L11.4625 7.35008L12.4834 8.37091L8.60419 12.2501H7.58335Z"
- fill="#FAFAFA"
- />
- </g>
- </g>
- <defs>
- <filter
- id="filter0_i_344_4970"
- x="1.16669"
- y="2.91675"
- width="12.6176"
- height="9.68628"
- filterUnits="userSpaceOnUse"
- colorInterpolationFilters="sRGB"
- >
- <feFlood floodOpacity="0" result="BackgroundImageFix" />
- <feBlend
- mode="normal"
- in="SourceGraphic"
- in2="BackgroundImageFix"
- result="shape"
- />
- <feColorMatrix
- in="SourceAlpha"
- type="matrix"
- values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
- result="hardAlpha"
- />
- <feOffset dx="0.353028" dy="0.353028" />
- <feGaussianBlur stdDeviation="0.706055" />
- <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
- <feColorMatrix
- type="matrix"
- values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
- />
- <feBlend
- mode="normal"
- in2="shape"
- result="effect1_innerShadow_344_4970"
- />
- </filter>
- </defs>
- </svg>
- )
-}
-
export function NotePreview({ document }: { document: DocumentWithMemories }) {
- const previewText = useMemo(() => {
- if (!document.content) return ""
- return extractTextFromTipTapContent(document.content).trim()
- }, [document.content])
-
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center gap-1">
- <NoteIcon />
+ <DocumentIcon type="note" className="w-4 h-4" />
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
Note
</p>
@@ -151,9 +29,9 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
{document.title}
</p>
)}
- {previewText && (
+ {document.summary && (
<p className="text-[10px] text-[#737373] line-clamp-4">
- {previewText}
+ {document.summary}
</p>
)}
</div>
diff --git a/apps/web/components/new/document-cards/website-preview.tsx b/apps/web/components/new/document-cards/website-preview.tsx
index ab3c5ad3..21ae9de2 100644
--- a/apps/web/components/new/document-cards/website-preview.tsx
+++ b/apps/web/components/new/document-cards/website-preview.tsx
@@ -3,50 +3,41 @@
import { useState } from "react"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
-import { dmSansClassName } from "@/lib/fonts"
-import { cn } from "@lib/utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
+type OgData = {
+ title?: string
+ image?: string
+}
+
export function WebsitePreview({
document,
+ ogData,
}: {
document: DocumentWithMemories
+ ogData?: OgData | null
}) {
const [imageError, setImageError] = useState(false)
+
const ogImage = (document as DocumentWithMemories & { ogImage?: string })
.ogImage
+ const displayOgImage = ogImage || ogData?.image
return (
<div className="bg-[#0B1017] rounded-[18px] overflow-hidden">
- {ogImage && !imageError ? (
+ {displayOgImage && !imageError ? (
<div className="relative w-full aspect-video bg-gray-100 overflow-hidden">
<img
- src={ogImage}
+ src={displayOgImage}
alt={document.title || "Website preview"}
className="w-full h-full object-cover"
onError={() => setImageError(true)}
loading="lazy"
/>
</div>
- ) : (
- <div className="p-3 gap-2">
- <p
- className={cn(
- dmSansClassName(),
- "text-[12px] font-semibold text-[#E5E5E5] line-clamp-2 mb-1",
- )}
- >
- {document.title || "Untitled Document"}
- </p>
- {document.content && (
- <p className="text-[10px] text-[#737373] line-clamp-3">
- {document.content}
- </p>
- )}
- </div>
- )}
+ ) : null}
</div>
)
}
diff --git a/apps/web/components/new/document-icon.tsx b/apps/web/components/new/document-icon.tsx
new file mode 100644
index 00000000..a2a502e1
--- /dev/null
+++ b/apps/web/components/new/document-icon.tsx
@@ -0,0 +1,322 @@
+"use client"
+
+import type React from "react"
+import { useState } from "react"
+import { MCPIcon } from "@/components/menu"
+import {
+ GoogleDocs,
+ GoogleSheets,
+ GoogleSlides,
+ GoogleDrive,
+ MicrosoftWord,
+ MicrosoftExcel,
+ MicrosoftPowerpoint,
+ MicrosoftOneNote,
+ OneDrive,
+ NotionDoc,
+ PDF,
+} from "@ui/assets/icons"
+import { Globe, FileText, Image } from "lucide-react"
+import { cn } from "@lib/utils"
+
+const BRAND_COLORS: Record<string, string> = {
+ google_doc: "#4285F4",
+ google_sheet: "#0F9D58",
+ google_slide: "#F4B400",
+ google_drive: "#4285F4",
+ notion: "#FFFFFF",
+ notion_doc: "#FFFFFF",
+ microsoft_word: "#2B579A",
+ word: "#2B579A",
+ microsoft_excel: "#217346",
+ excel: "#217346",
+ microsoft_powerpoint: "#D24726",
+ powerpoint: "#D24726",
+ microsoft_onenote: "#7719AA",
+ onenote: "#7719AA",
+ onedrive: "#0078D4",
+ pdf: "#FF7673",
+ text: "#FAFAFA",
+ note: "#FAFAFA",
+ image: "#FAFAFA",
+ video: "#FAFAFA",
+ webpage: "#737373",
+ url: "#737373",
+}
+
+function getFaviconUrl(url: string): string {
+ try {
+ const domain = new URL(url).hostname
+ return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
+ } catch {
+ return ""
+ }
+}
+
+function FaviconIcon({
+ url,
+ className,
+}: {
+ url: string
+ className?: string
+}) {
+ const [hasError, setHasError] = useState(false)
+ const faviconUrl = getFaviconUrl(url)
+
+ if (hasError || !faviconUrl) {
+ return <Globe className={cn("text-[#737373]", className)} />
+ }
+
+ return (
+ <img
+ src={faviconUrl}
+ alt="Website favicon"
+ className={className}
+ style={{
+ width: "1em",
+ height: "1em",
+ objectFit: "contain",
+ }}
+ onError={() => setHasError(true)}
+ />
+ )
+}
+
+function YouTubeIcon({ className }: { className?: string }) {
+ return (
+ <svg
+ viewBox="0 0 20 14"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ className={className}
+ >
+ <title>YouTube</title>
+ <path
+ d="M8 10L13.19 7L8 4V10ZM19.56 2.17C19.69 2.64 19.78 3.27 19.84 4.07C19.91 4.87 19.94 5.56 19.94 6.16L20 7C20 9.19 19.84 10.8 19.56 11.83C19.31 12.73 18.73 13.31 17.83 13.56C17.36 13.69 16.5 13.78 15.18 13.84C13.88 13.91 12.69 13.94 11.59 13.94L10 14C5.81 14 3.2 13.84 2.17 13.56C1.27 13.31 0.69 12.73 0.44 11.83C0.31 11.36 0.22 10.73 0.16 9.93C0.0900001 9.13 0.0599999 8.44 0.0599999 7.84L0 7C0 4.81 0.16 3.2 0.44 2.17C0.69 1.27 1.27 0.69 2.17 0.44C2.64 0.31 3.5 0.22 4.82 0.16C6.12 0.0899998 7.31 0.0599999 8.41 0.0599999L10 0C14.19 0 16.8 0.16 17.83 0.44C18.73 0.69 19.31 1.27 19.56 2.17Z"
+ fill="#FF0000"
+ />
+ <path d="M8 10L13.19 7L8 4V10Z" fill="white" />
+ </svg>
+ )
+}
+
+function TextDocumentIcon({ className }: { className?: string }) {
+ return (
+ <svg
+ viewBox="0 0 18 14"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ className={className}
+ >
+ <title>Text Document</title>
+ <path
+ d="M3.33333 8.33333H9.16667L10.8333 6.66667H3.33333V8.33333ZM3.33333 5H8.33333V3.33333H3.33333V5ZM1.66667 1.66667V10H7.5L5.83333 11.6667H0V0H16.6667V2.5H15V1.66667H1.66667ZM17.4167 6.08333C17.4861 6.15278 17.5208 6.22917 17.5208 6.3125C17.5208 6.39583 17.4861 6.47222 17.4167 6.54167L16.6667 7.29167L15.2083 5.83333L15.9583 5.08333C16.0278 5.01389 16.1042 4.97917 16.1875 4.97917C16.2708 4.97917 16.3472 5.01389 16.4167 5.08333L17.4167 6.08333ZM9.16667 13.3333V11.875L14.7083 6.33333L16.1667 7.79167L10.625 13.3333H9.16667Z"
+ fill="currentColor"
+ />
+ </svg>
+ )
+}
+
+function XIcon({ className }: { className?: string }) {
+ return (
+ <span className={cn("font-bold", className)} style={{ color: "#FFFFFF" }}>
+ 𝕏
+ </span>
+ )
+}
+
+export interface DocumentIconProps {
+ type: string | null | undefined
+ source?: string | null
+ url?: string | null
+ className?: string
+}
+
+export function DocumentIcon({
+ type,
+ source,
+ url,
+ className,
+}: DocumentIconProps) {
+ const iconClassName = cn("w-4 h-4", className)
+
+ if (source === "mcp") {
+ return <MCPIcon className={iconClassName} />
+ }
+
+ if (url?.includes("youtube.com") || url?.includes("youtu.be")) {
+ return <YouTubeIcon className={iconClassName} />
+ }
+
+ if (
+ type === "webpage" ||
+ type === "url" ||
+ (url && (type === "unknown" || !type))
+ ) {
+ if (url) {
+ return <FaviconIcon url={url} className={iconClassName} />
+ }
+ return <Globe className={iconClassName} style={{ color: "#737373" }} />
+ }
+
+ const brandColor = BRAND_COLORS[type ?? ""] ?? "#FAFAFA"
+
+ switch (type) {
+ case "tweet":
+ return <XIcon className={iconClassName} />
+
+ case "google_doc":
+ return (
+ <span style={{ color: brandColor }}>
+ <GoogleDocs className={iconClassName} />
+ </span>
+ )
+
+ case "google_sheet":
+ return (
+ <span style={{ color: brandColor }}>
+ <GoogleSheets className={iconClassName} />
+ </span>
+ )
+
+ case "google_slide":
+ return (
+ <span style={{ color: brandColor }}>
+ <GoogleSlides className={iconClassName} />
+ </span>
+ )
+
+ case "google_drive":
+ return (
+ <span style={{ color: brandColor }}>
+ <GoogleDrive className={iconClassName} />
+ </span>
+ )
+
+ case "notion":
+ case "notion_doc":
+ return (
+ <span style={{ color: brandColor }}>
+ <NotionDoc className={iconClassName} />
+ </span>
+ )
+
+ case "word":
+ case "microsoft_word":
+ return (
+ <span style={{ color: brandColor }}>
+ <MicrosoftWord className={iconClassName} />
+ </span>
+ )
+
+ case "excel":
+ case "microsoft_excel":
+ return (
+ <span style={{ color: brandColor }}>
+ <MicrosoftExcel className={iconClassName} />
+ </span>
+ )
+
+ case "powerpoint":
+ case "microsoft_powerpoint":
+ return (
+ <span style={{ color: brandColor }}>
+ <MicrosoftPowerpoint className={iconClassName} />
+ </span>
+ )
+
+ case "onenote":
+ case "microsoft_onenote":
+ return (
+ <span style={{ color: brandColor }}>
+ <MicrosoftOneNote className={iconClassName} />
+ </span>
+ )
+
+ case "onedrive":
+ return (
+ <span style={{ color: brandColor }}>
+ <OneDrive className={iconClassName} />
+ </span>
+ )
+
+ case "pdf":
+ return <PDF className={iconClassName} />
+
+ case "youtube":
+ case "video":
+ return <YouTubeIcon className={iconClassName} />
+
+ case "image":
+ return <Image className={iconClassName} style={{ color: brandColor }} />
+
+ case "text":
+ case "note":
+ return <TextDocumentIcon className={iconClassName} />
+
+ default:
+ return <FileText className={iconClassName} style={{ color: "#FAFAFA" }} />
+ }
+}
+
+/**
+ * @deprecated Use <DocumentIcon /> component instead
+ * Backward-compatible function for legacy code
+ */
+export function getDocumentIcon(
+ type: string,
+ className: string,
+ source?: string,
+ url?: string,
+): React.ReactNode {
+ return (
+ <DocumentIcon type={type} source={source} url={url} className={className} />
+ )
+}
+
+export function getDocumentTypeLabel(type: string | null | undefined): string {
+ switch (type) {
+ case "google_doc":
+ return "Google Docs"
+ case "google_sheet":
+ return "Google Sheets"
+ case "google_slide":
+ return "Google Slides"
+ case "google_drive":
+ return "Google Drive"
+ case "notion":
+ case "notion_doc":
+ return "Notion"
+ case "word":
+ case "microsoft_word":
+ return "Word"
+ case "excel":
+ case "microsoft_excel":
+ return "Excel"
+ case "powerpoint":
+ case "microsoft_powerpoint":
+ return "PowerPoint"
+ case "onenote":
+ case "microsoft_onenote":
+ return "OneNote"
+ case "onedrive":
+ return "OneDrive"
+ case "pdf":
+ return "PDF"
+ case "youtube":
+ case "video":
+ return "Video"
+ case "image":
+ return "Image"
+ case "text":
+ case "note":
+ return "Note"
+ case "tweet":
+ return "Tweet"
+ case "webpage":
+ case "url":
+ return "Webpage"
+ default:
+ return "Document"
+ }
+}
diff --git a/apps/web/components/new/document-modal/content/google-doc.tsx b/apps/web/components/new/document-modal/content/google-doc.tsx
new file mode 100644
index 00000000..562bac12
--- /dev/null
+++ b/apps/web/components/new/document-modal/content/google-doc.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import { useState } from "react"
+import { Loader2 } from "lucide-react"
+import {
+ extractGoogleDocId,
+ getGoogleEmbedUrl,
+} from "@/lib/url-helpers"
+
+interface GoogleDocViewerProps {
+ url: string | null | undefined
+ customId: string | null | undefined
+ type: "google_doc" | "google_sheet" | "google_slide"
+}
+
+export function GoogleDocViewer({ url, customId, type }: GoogleDocViewerProps) {
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState<string | null>(null)
+
+ const docId = customId ?? (url ? extractGoogleDocId(url) : null)
+
+ if (!docId) {
+ return (
+ <div className="flex items-center justify-center h-full text-gray-400">
+ Unable to load document - no document ID found
+ </div>
+ )
+ }
+
+ const embedUrl = getGoogleEmbedUrl(docId, type)
+
+ const typeLabels = {
+ google_doc: "Google Doc",
+ google_sheet: "Google Sheet",
+ google_slide: "Google Slides",
+ }
+
+ return (
+ <div className="flex flex-col h-full w-full overflow-hidden">
+ {loading && (
+ <div className="absolute inset-0 flex items-center justify-center bg-[#14161A] z-10">
+ <div className="flex items-center gap-2 text-gray-400">
+ <Loader2 className="w-5 h-5 animate-spin" />
+ <span>Loading {typeLabels[type]}...</span>
+ </div>
+ </div>
+ )}
+ {error && (
+ <div className="flex items-center justify-center h-full text-red-400">
+ {error}
+ </div>
+ )}
+ <iframe
+ src={embedUrl}
+ className="flex-1 w-full h-full border-0 rounded-[14px]"
+ onLoad={() => setLoading(false)}
+ onError={() => {
+ setLoading(false)
+ setError("Failed to load document")
+ }}
+ allow="autoplay"
+ title={typeLabels[type]}
+ />
+ </div>
+ )
+}
diff --git a/apps/web/components/new/document-modal/content/image-preview.tsx b/apps/web/components/new/document-modal/content/image-preview.tsx
new file mode 100644
index 00000000..31d5c1c0
--- /dev/null
+++ b/apps/web/components/new/document-modal/content/image-preview.tsx
@@ -0,0 +1,56 @@
+"use client"
+
+import { useState } from "react"
+import { cn } from "@lib/utils"
+
+interface ImagePreviewProps {
+ url: string
+ title?: string | null
+}
+
+export function ImagePreview({ url, title }: ImagePreviewProps) {
+ const [imageError, setImageError] = useState(false)
+ const [isLoading, setIsLoading] = useState(true)
+
+ if (imageError || !url) {
+ return (
+ <div className="flex items-center justify-center h-full text-[#737373]">
+ <p>Failed to load image</p>
+ </div>
+ )
+ }
+
+ return (
+ <div className="relative w-full h-full overflow-hidden flex items-center justify-center bg-[#0B1017]">
+ {isLoading && (
+ <div className="absolute inset-0 bg-cover bg-center animate-pulse">
+ <div className="w-full h-full bg-[#1B1F24]" />
+ </div>
+ )}
+ <div
+ className="absolute inset-0 bg-cover bg-center"
+ style={{
+ backgroundImage: `url(${url})`,
+ filter: "blur(100px)",
+ transform: "scale(1.1)",
+ opacity: isLoading ? 0.5 : 1,
+ }}
+ />
+ <div className="absolute inset-0 bg-black/30" />
+ <img
+ src={url}
+ alt={title || "Image preview"}
+ className={cn(
+ "relative max-w-full max-h-full w-auto h-auto object-contain z-10",
+ isLoading && "opacity-0",
+ )}
+ onError={() => {
+ setImageError(true)
+ setIsLoading(false)
+ }}
+ onLoad={() => setIsLoading(false)}
+ loading="lazy"
+ />
+ </div>
+ )
+}
diff --git a/apps/web/components/new/document-modal/content/index.tsx b/apps/web/components/new/document-modal/content/index.tsx
new file mode 100644
index 00000000..c06bc550
--- /dev/null
+++ b/apps/web/components/new/document-modal/content/index.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
+import type { z } from "zod"
+import dynamic from "next/dynamic"
+import { isTwitterUrl } from "@/lib/url-helpers"
+import { ImagePreview } from "./image-preview"
+import { TweetContent } from "./tweet"
+import { NotionDoc } from "./notion-doc"
+import { YoutubeVideo } from "./yt-video"
+import { WebPageContent } from "./web-page"
+import { TextEditorContent } from "./text-editor-content"
+import { GoogleDocViewer } from "./google-doc"
+import type { TextEditorProps } from "./text-editor-content"
+
+export type { TextEditorProps }
+
+const PdfViewer = dynamic(
+ () => import("./pdf").then((mod) => ({ default: mod.PdfViewer })),
+ {
+ ssr: false,
+ loading: () => (
+ <div className="flex items-center justify-center h-full text-gray-400">
+ Loading PDF viewer...
+ </div>
+ ),
+ },
+) as typeof import("./pdf").PdfViewer
+
+type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
+type DocumentWithMemories = DocumentsResponse["documents"][0]
+
+interface DocumentContentProps {
+ document: DocumentWithMemories | null
+ textEditorProps: TextEditorProps
+}
+
+type ContentType =
+ | "image"
+ | "tweet"
+ | "text"
+ | "pdf"
+ | "notion"
+ | "youtube"
+ | "webpage"
+ | "google_doc"
+ | "google_sheet"
+ | "google_slide"
+ | null
+
+function getContentType(document: DocumentWithMemories | null): ContentType {
+ if (!document) return null
+
+ const isImage =
+ document.type === "image" ||
+ document.metadata?.mimeType?.toString().startsWith("image/")
+
+ if (isImage && document.url) return "image"
+ if (
+ document.type === "tweet" ||
+ (document.url && isTwitterUrl(document.url))
+ )
+ return "tweet"
+ if (document.type === "text") return "text"
+ if (document.type === "pdf") return "pdf"
+ if (document.type === "notion_doc") return "notion"
+ if (document.type === "google_doc") return "google_doc"
+ if (document.type === "google_sheet") return "google_sheet"
+ if (document.type === "google_slide") return "google_slide"
+ if (document.url?.includes("youtube.com")) return "youtube"
+ if (document.type === "webpage") return "webpage"
+
+ return null
+}
+
+export function DocumentContent({
+ document,
+ textEditorProps,
+}: DocumentContentProps) {
+ const contentType = getContentType(document)
+
+ if (!document || !contentType) return null
+
+ switch (contentType) {
+ case "image":
+ return (
+ <ImagePreview url={document.url ?? ""} title={document.title} />
+ )
+
+ case "tweet":
+ return (
+ <TweetContent
+ url={document.url}
+ tweetMetadata={document.metadata?.sm_internal_twitter_metadata}
+ />
+ )
+
+ case "text":
+ return <TextEditorContent {...textEditorProps} />
+
+ case "pdf":
+ return <PdfViewer url={document.url} />
+
+ case "notion":
+ return <NotionDoc content={document.content ?? ""} />
+
+ case "youtube":
+ return <YoutubeVideo url={document.url ?? ""} />
+
+ case "webpage":
+ return <WebPageContent content={document.content ?? ""} />
+
+ case "google_doc":
+ return (
+ <GoogleDocViewer
+ url={document.url}
+ customId={document.customId}
+ type="google_doc"
+ />
+ )
+
+ case "google_sheet":
+ return (
+ <GoogleDocViewer
+ url={document.url}
+ customId={document.customId}
+ type="google_sheet"
+ />
+ )
+
+ case "google_slide":
+ return (
+ <GoogleDocViewer
+ url={document.url}
+ customId={document.customId}
+ type="google_slide"
+ />
+ )
+
+ default:
+ return null
+ }
+}
diff --git a/apps/web/components/new/document-modal/content/text-editor-content.tsx b/apps/web/components/new/document-modal/content/text-editor-content.tsx
new file mode 100644
index 00000000..98c5beb4
--- /dev/null
+++ b/apps/web/components/new/document-modal/content/text-editor-content.tsx
@@ -0,0 +1,91 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import { TextEditor } from "../../text-editor"
+import { motion, AnimatePresence } from "motion/react"
+import { Button } from "@repo/ui/components/button"
+import { Loader2 } from "lucide-react"
+
+export interface TextEditorProps {
+ documentId: string
+ editorResetNonce: number
+ initialEditorContent: string | undefined
+ hasUnsavedChanges: boolean
+ isSaving: boolean
+ onContentChange: (content: string) => void
+ onSave: () => void
+ onReset: () => void
+}
+
+export function TextEditorContent({
+ documentId,
+ editorResetNonce,
+ initialEditorContent,
+ hasUnsavedChanges,
+ isSaving,
+ onContentChange,
+ onSave,
+ onReset,
+}: TextEditorProps) {
+ return (
+ <>
+ <div className="p-4 overflow-y-auto flex-1 scrollbar-thin">
+ <TextEditor
+ key={`${documentId}-${editorResetNonce}`}
+ content={initialEditorContent}
+ onContentChange={onContentChange}
+ onSubmit={onSave}
+ />
+ </div>
+ <AnimatePresence>
+ {hasUnsavedChanges && (
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, y: 20 }}
+ transition={{ duration: 0.2 }}
+ className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-3 px-4 py-2 bg-[#1B1F24] rounded-full shadow-[0_4px_20px_rgba(0,0,0,0.4),inset_1px_1px_1px_rgba(255,255,255,0.1)]"
+ >
+ <span className="text-sm text-[#737373]">Unsaved changes</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={onReset}
+ disabled={isSaving}
+ className="text-[#737373]/80 hover:text-white rounded-full px-3"
+ >
+ Cancel
+ </Button>
+ <Button
+ variant="insideOut"
+ size="sm"
+ onClick={onSave}
+ disabled={isSaving}
+ className="hover:text-white rounded-full px-4"
+ >
+ {isSaving ? (
+ <>
+ <Loader2 className="size-4 animate-spin mr-1" />
+ Saving...
+ </>
+ ) : (
+ <>
+ Save
+ <span
+ className={cn(
+ "bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm px-1 py-0.5 text-[10px] flex items-center justify-center",
+ dmSansClassName(),
+ )}
+ >
+ ⌘+Enter
+ </span>
+ </>
+ )}
+ </Button>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </>
+ )
+}
diff --git a/apps/web/components/new/document-modal/document-icon.tsx b/apps/web/components/new/document-modal/document-icon.tsx
index cb279e49..cef93d01 100644
--- a/apps/web/components/new/document-modal/document-icon.tsx
+++ b/apps/web/components/new/document-modal/document-icon.tsx
@@ -1,263 +1,10 @@
-import { MCPIcon } from "@/components/menu"
-import { colors } from "@repo/ui/memory-graph/constants"
-import {
- GoogleDocs,
- MicrosoftWord,
- NotionDoc,
- GoogleDrive,
- GoogleSheets,
- GoogleSlides,
- OneDrive,
- MicrosoftOneNote,
- MicrosoftPowerpoint,
- MicrosoftExcel,
-} from "@ui/assets/icons"
-import { Globe } from "lucide-react"
-import { useState } from "react"
-
-const getFaviconUrl = (url: string): string => {
- try {
- const domain = new URL(url).hostname
- return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
- } catch {
- return ""
- }
-}
-
-const FaviconIcon = ({
- url,
- className,
- iconProps,
-}: {
- url: string
- className: string
- iconProps: { className: string; style: { color: string } }
-}) => {
- const [hasError, setHasError] = useState(false)
- const faviconUrl = getFaviconUrl(url)
-
- if (hasError || !faviconUrl) {
- return <Globe {...iconProps} />
- }
-
- return (
- <img
- src={faviconUrl}
- alt="Website favicon"
- className={className}
- style={{
- width: "2em",
- height: "2em",
- objectFit: "contain",
- }}
- onError={() => setHasError(true)}
- />
- )
-}
-
-const PDFIcon = ({ className }: { className: string }) => {
- return (
- <svg
- width="8"
- height="10"
- viewBox="0 0 8 10"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- className={className}
- >
- <title>PDF Icon</title>
- <g filter="url(#filter0_i_719_6586)">
- <path
- d="M1 10C0.725 10 0.489583 9.90208 0.29375 9.70625C0.0979167 9.51042 0 9.275 0 9V1C0 0.725 0.0979167 0.489583 0.29375 0.29375C0.489583 0.0979167 0.725 0 1 0H5L8 3V9C8 9.275 7.90208 9.51042 7.70625 9.70625C7.51042 9.90208 7.275 10 7 10H1ZM4.5 3.5V1H1V9H7V3.5H4.5Z"
- fill="#FF7673"
- />
- </g>
- <defs>
- <filter
- id="filter0_i_719_6586"
- x="0"
- y="0"
- width="8.25216"
- height="10.2522"
- filterUnits="userSpaceOnUse"
- colorInterpolationFilters="sRGB"
- >
- <feFlood floodOpacity="0" result="BackgroundImageFix" />
- <feBlend
- mode="normal"
- in="SourceGraphic"
- in2="BackgroundImageFix"
- result="shape"
- />
- <feColorMatrix
- in="SourceAlpha"
- type="matrix"
- values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
- result="hardAlpha"
- />
- <feOffset dx="0.252163" dy="0.252163" />
- <feGaussianBlur stdDeviation="0.504325" />
- <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
- <feColorMatrix
- type="matrix"
- values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
- />
- <feBlend
- mode="normal"
- in2="shape"
- result="effect1_innerShadow_719_6586"
- />
- </filter>
- </defs>
- </svg>
- )
-}
-
-const YouTubeIcon = ({ className }: { className: string }) => {
- return (
- <svg
- width="20"
- height="14"
- viewBox="0 0 20 14"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- className={className}
- >
- <title>YouTube Icon</title>
- <path
- d="M8 10L13.19 7L8 4V10ZM19.56 2.17C19.69 2.64 19.78 3.27 19.84 4.07C19.91 4.87 19.94 5.56 19.94 6.16L20 7C20 9.19 19.84 10.8 19.56 11.83C19.31 12.73 18.73 13.31 17.83 13.56C17.36 13.69 16.5 13.78 15.18 13.84C13.88 13.91 12.69 13.94 11.59 13.94L10 14C5.81 14 3.2 13.84 2.17 13.56C1.27 13.31 0.69 12.73 0.44 11.83C0.31 11.36 0.22 10.73 0.16 9.93C0.0900001 9.13 0.0599999 8.44 0.0599999 7.84L0 7C0 4.81 0.16 3.2 0.44 2.17C0.69 1.27 1.27 0.69 2.17 0.44C2.64 0.31 3.5 0.22 4.82 0.16C6.12 0.0899998 7.31 0.0599999 8.41 0.0599999L10 0C14.19 0 16.8 0.16 17.83 0.44C18.73 0.69 19.31 1.27 19.56 2.17Z"
- fill="#FF0034"
- />
- <path d="M8 10L13.19 7L8 4V10Z" fill="white" />
- </svg>
- )
-}
-
-const TextDocumentIcon = ({ className }: { className: string }) => {
- return (
- <svg
- width="18"
- height="14"
- viewBox="0 0 18 14"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- className={className}
- >
- <title>Text Document Icon</title>
- <g filter="url(#filter0_i_724_34196)">
- <path
- d="M3.33333 8.33333H9.16667L10.8333 6.66667H3.33333V8.33333ZM3.33333 5H8.33333V3.33333H3.33333V5ZM1.66667 1.66667V10H7.5L5.83333 11.6667H0V0H16.6667V2.5H15V1.66667H1.66667ZM17.4167 6.08333C17.4861 6.15278 17.5208 6.22917 17.5208 6.3125C17.5208 6.39583 17.4861 6.47222 17.4167 6.54167L16.6667 7.29167L15.2083 5.83333L15.9583 5.08333C16.0278 5.01389 16.1042 4.97917 16.1875 4.97917C16.2708 4.97917 16.3472 5.01389 16.4167 5.08333L17.4167 6.08333ZM9.16667 13.3333V11.875L14.7083 6.33333L16.1667 7.79167L10.625 13.3333H9.16667Z"
- fill="#FAFAFA"
- />
- </g>
- <defs>
- <filter
- id="filter0_i_724_34196"
- x="0"
- y="0"
- width="18.0253"
- height="13.8376"
- filterUnits="userSpaceOnUse"
- colorInterpolationFilters="sRGB"
- >
- <feFlood floodOpacity="0" result="BackgroundImageFix" />
- <feBlend
- mode="normal"
- in="SourceGraphic"
- in2="BackgroundImageFix"
- result="shape"
- />
- <feColorMatrix
- in="SourceAlpha"
- type="matrix"
- values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
- result="hardAlpha"
- />
- <feOffset dx="0.504325" dy="0.504325" />
- <feGaussianBlur stdDeviation="1.00865" />
- <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
- <feColorMatrix
- type="matrix"
- values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
- />
- <feBlend
- mode="normal"
- in2="shape"
- result="effect1_innerShadow_724_34196"
- />
- </filter>
- </defs>
- </svg>
- )
-}
-
-export const getDocumentIcon = (
- type: string,
- className: string,
- source?: string,
- url?: string,
-) => {
- const iconProps = {
- className,
- style: { color: colors.text.muted },
- }
-
- if (source === "mcp") {
- return <MCPIcon {...iconProps} />
- }
-
- if (url?.includes("youtube.com") || url?.includes("youtu.be")) {
- return <YouTubeIcon className={className} />
- }
-
- if (
- type === "webpage" ||
- type === "url" ||
- (url && (type === "unknown" || !type))
- ) {
- if (url) {
- return (
- <FaviconIcon url={url} className={className} iconProps={iconProps} />
- )
- }
-
- return <Globe {...iconProps} />
- }
-
- switch (type) {
- case "tweet":
- return <span className={className}>𝕏</span>
- case "google_doc":
- return <GoogleDocs {...iconProps} />
- case "google_sheet":
- return <GoogleSheets {...iconProps} />
- case "google_slide":
- return <GoogleSlides {...iconProps} />
- case "google_drive":
- return <GoogleDrive {...iconProps} />
- case "notion":
- case "notion_doc":
- return <NotionDoc {...iconProps} />
- case "word":
- case "microsoft_word":
- return <MicrosoftWord {...iconProps} />
- case "excel":
- case "microsoft_excel":
- return <MicrosoftExcel {...iconProps} />
- case "powerpoint":
- case "microsoft_powerpoint":
- return <MicrosoftPowerpoint {...iconProps} />
- case "onenote":
- case "microsoft_onenote":
- return <MicrosoftOneNote {...iconProps} />
- case "onedrive":
- return <OneDrive {...iconProps} />
- case "pdf":
- return <PDFIcon className={className} />
- case "youtube":
- case "video":
- return <YouTubeIcon className={className} />
- default:
- return <TextDocumentIcon className={className} />
- }
-}
+/**
+ * @deprecated Import from "@/components/new/document-icon" instead
+ * This file is kept for backward compatibility
+ */
+export {
+ DocumentIcon,
+ getDocumentIcon,
+ getDocumentTypeLabel,
+ type DocumentIconProps,
+} from "@/components/new/document-icon"
diff --git a/apps/web/components/new/document-modal/graph-list-memories.tsx b/apps/web/components/new/document-modal/graph-list-memories.tsx
index 5f927459..49f918c2 100644
--- a/apps/web/components/new/document-modal/graph-list-memories.tsx
+++ b/apps/web/components/new/document-modal/graph-list-memories.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react"
import { cn } from "@lib/utils"
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs"
@@ -168,6 +169,22 @@ export function GraphListMemories({
}: {
memoryEntries: MemoryEntry[]
}) {
+ const [expandedMemories, setExpandedMemories] = useState<Set<string>>(
+ new Set(),
+ )
+
+ const toggleMemory = (memoryId: string) => {
+ setExpandedMemories((prev) => {
+ const next = new Set(prev)
+ if (next.has(memoryId)) {
+ next.delete(memoryId)
+ } else {
+ next.add(memoryId)
+ }
+ return next
+ })
+ }
+
return (
<div
id="document-memories"
@@ -241,7 +258,7 @@ export function GraphListMemories({
</TabsTrigger>
</TabsList>
</Tabs>
- <div className="grid grid-cols-2 gap-2 pt-3 overflow-y-auto pr-1 scrollbar-thin">
+ <div className="grid grid-cols-2 gap-2 pt-3 overflow-y-auto pr-1 scrollbar-thin items-start">
{memoryEntries.map((memory, idx) => {
const isClickable =
memory.url &&
@@ -265,9 +282,18 @@ export function GraphListMemories({
</div>
)}
{memory.memory && (
- <div className="text-xs text-[#525D6E]/80 line-clamp-2">
+ <button
+ type="button"
+ className={cn(
+ "text-xs text-[#525D6E] cursor-pointer transition-all text-left w-full",
+ expandedMemories.has(memory.id)
+ ? ""
+ : "line-clamp-2",
+ )}
+ onClick={() => toggleMemory(memory.id)}
+ >
{memory.memory}
- </div>
+ </button>
)}
{memory.url && (
<div className="text-xs text-[#525D6E] truncate">
@@ -296,7 +322,7 @@ export function GraphListMemories({
if (isClickable) {
return (
<a
- className="block p-2 bg-[#0C1829]/50 rounded-md border border-[#525D6E]/20 hover:bg-[#0C1829]/70 transition-colors cursor-pointer"
+ className="block p-2 bg-[#0C1829]/50 rounded-md border border-[#525D6E]/20 hover:bg-[#0C1829]/70 transition-colors cursor-pointer self-start"
href={memory.url}
key={memory.id || idx}
rel="noopener noreferrer"
@@ -309,7 +335,7 @@ export function GraphListMemories({
return (
<div
- className={cn("bg-[#0C1829] rounded-xl")}
+ className={cn("bg-[#0C1829] rounded-xl self-start")}
key={memory.id || idx}
>
{content}
diff --git a/apps/web/components/new/document-modal/index.tsx b/apps/web/components/new/document-modal/index.tsx
index 8cf5d195..145a7a13 100644
--- a/apps/web/components/new/document-modal/index.tsx
+++ b/apps/web/components/new/document-modal/index.tsx
@@ -12,38 +12,18 @@ import {
import type { z } from "zod"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@lib/utils"
-import dynamic from "next/dynamic"
import { Title } from "./title"
import { Summary as DocumentSummary } from "./summary"
import { dmSansClassName } from "@/lib/fonts"
import { GraphListMemories, type MemoryEntry } from "./graph-list-memories"
-import { YoutubeVideo } from "./content/yt-video"
-import { TweetContent } from "./content/tweet"
-import { isTwitterUrl } from "@/lib/url-helpers"
-import { NotionDoc } from "./content/notion-doc"
-import { TextEditor } from "../text-editor"
+import { DocumentContent } from "./content"
import { useState, useEffect, useCallback, useMemo } from "react"
import { motion, AnimatePresence } from "motion/react"
-import { Button } from "@repo/ui/components/button"
import { useDocumentMutations } from "@/hooks/use-document-mutations"
import type { UseMutationResult } from "@tanstack/react-query"
import { toast } from "sonner"
-import { WebPageContent } from "./content/web-page"
import { useIsMobile } from "@hooks/use-mobile"
-// Dynamically importing to prevent DOMMatrix error
-const PdfViewer = dynamic(
- () => import("./content/pdf").then((mod) => ({ default: mod.PdfViewer })),
- {
- ssr: false,
- loading: () => (
- <div className="flex items-center justify-center h-full text-gray-400">
- Loading PDF viewer...
- </div>
- ),
- },
-) as typeof import("./content/pdf").PdfViewer
-
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@@ -200,6 +180,28 @@ export function DocumentModal({
)
}, [_document?.id, draftContentString, updateMutation])
+ const textEditorProps = useMemo(
+ () => ({
+ documentId: _document?.id ?? "",
+ editorResetNonce,
+ initialEditorContent,
+ hasUnsavedChanges,
+ isSaving: updateMutation.isPending,
+ onContentChange: setDraftContentString,
+ onSave: handleSave,
+ onReset: resetEditor,
+ }),
+ [
+ _document?.id,
+ editorResetNonce,
+ initialEditorContent,
+ hasUnsavedChanges,
+ updateMutation.isPending,
+ handleSave,
+ resetEditor,
+ ],
+ )
+
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
@@ -267,87 +269,10 @@ export function DocumentModal({
"bg-[#14161A] rounded-[14px] overflow-hidden flex flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] relative",
)}
>
- {(_document?.type === "tweet" ||
- (_document?.url && isTwitterUrl(_document.url))) && (
- <TweetContent
- url={_document?.url}
- tweetMetadata={
- _document?.metadata?.sm_internal_twitter_metadata
- }
- />
- )}
- {_document?.type === "text" && (
- <>
- <div className="p-4 overflow-y-auto flex-1 scrollbar-thin">
- <TextEditor
- key={`${_document.id}-${editorResetNonce}`}
- content={initialEditorContent}
- onContentChange={setDraftContentString}
- onSubmit={handleSave}
- />
- </div>
- <AnimatePresence>
- {hasUnsavedChanges && (
- <motion.div
- initial={{ opacity: 0, y: 20 }}
- animate={{ opacity: 1, y: 0 }}
- exit={{ opacity: 0, y: 20 }}
- transition={{ duration: 0.2 }}
- className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-3 px-4 py-2 bg-[#1B1F24] rounded-full shadow-[0_4px_20px_rgba(0,0,0,0.4),inset_1px_1px_1px_rgba(255,255,255,0.1)]"
- >
- <span className="text-sm text-[#737373]">
- Unsaved changes
- </span>
- <Button
- variant="ghost"
- size="sm"
- onClick={resetEditor}
- disabled={updateMutation.isPending}
- className="text-[#737373]/80 hover:text-white rounded-full px-3"
- >
- Cancel
- </Button>
- <Button
- variant="insideOut"
- size="sm"
- onClick={handleSave}
- disabled={updateMutation.isPending}
- className="hover:text-white rounded-full px-4"
- >
- {updateMutation.isPending ? (
- <>
- <Loader2 className="size-4 animate-spin mr-1" />
- Saving...
- </>
- ) : (
- <>
- Save
- <span
- className={cn(
- "bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm px-1 py-0.5 text-[10px] flex items-center justify-center",
- dmSansClassName(),
- )}
- >
- ⌘+Enter
- </span>
- </>
- )}
- </Button>
- </motion.div>
- )}
- </AnimatePresence>
- </>
- )}
- {_document?.type === "pdf" && <PdfViewer url={_document.url} />}
- {_document?.type === "notion_doc" && (
- <NotionDoc content={_document.content ?? ""} />
- )}
- {_document?.url?.includes("youtube.com") && (
- <YoutubeVideo url={_document.url} />
- )}
- {_document?.type === "webpage" && (
- <WebPageContent content={_document.content ?? ""} />
- )}
+ <DocumentContent
+ document={_document}
+ textEditorProps={textEditorProps}
+ />
</div>
<div
id="document-memories-summary"
diff --git a/apps/web/components/new/document-modal/title.tsx b/apps/web/components/new/document-modal/title.tsx
index 59eb285e..6f3ecb1e 100644
--- a/apps/web/components/new/document-modal/title.tsx
+++ b/apps/web/components/new/document-modal/title.tsx
@@ -1,12 +1,8 @@
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
-import type { DocumentTypeEnum } from "@repo/validation/schemas"
-import type { z } from "zod"
-import { getDocumentIcon } from "@/components/new/document-modal/document-icon"
+import { DocumentIcon } from "@/components/new/document-icon"
-type DocumentType = z.infer<typeof DocumentTypeEnum>
-
-function getFileExtension(documentType: DocumentType): string | null {
+function getFileExtension(documentType: string): string | null {
switch (documentType) {
case "pdf":
return ".pdf"
@@ -21,7 +17,7 @@ export function Title({
url,
}: {
title: string | null | undefined
- documentType: DocumentType
+ documentType: string
url?: string | null
}) {
const extension = getFileExtension(documentType)
@@ -33,13 +29,8 @@ export function Title({
"text-[16px] font-semibold text-[#FAFAFA] leading-[125%] flex items-center gap-3 min-w-0",
)}
>
- <div className="pl-1 flex items-center gap-1 w-5 h-5 shrink-0">
- {getDocumentIcon(
- documentType as DocumentType,
- "w-5 h-5",
- undefined,
- url ?? undefined,
- )}
+ <div className="pl-1 flex items-center gap-1 shrink-0">
+ <DocumentIcon type={documentType} url={url} className="w-5 h-5" />
{extension && (
<p
className={cn(dmSansClassName(), "text-[12px] font-semibold")}
diff --git a/apps/web/components/new/documents-command-palette.tsx b/apps/web/components/new/documents-command-palette.tsx
new file mode 100644
index 00000000..48cafc74
--- /dev/null
+++ b/apps/web/components/new/documents-command-palette.tsx
@@ -0,0 +1,233 @@
+"use client"
+
+import { useState, useCallback, useMemo, useEffect, useRef } from "react"
+import { useQueryClient } from "@tanstack/react-query"
+import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
+import type { z } from "zod"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import { useIsMobile } from "@hooks/use-mobile"
+import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
+import { SearchIcon } from "lucide-react"
+import { DocumentIcon } from "@/components/new/document-icon"
+
+type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
+type DocumentWithMemories = DocumentsResponse["documents"][0]
+
+interface DocumentsCommandPaletteProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ projectId: string
+ onOpenDocument: (document: DocumentWithMemories) => void
+}
+
+export function DocumentsCommandPalette({
+ open,
+ onOpenChange,
+ projectId,
+ onOpenDocument,
+}: DocumentsCommandPaletteProps) {
+ const isMobile = useIsMobile()
+ const queryClient = useQueryClient()
+ const [search, setSearch] = useState("")
+ const [selectedIndex, setSelectedIndex] = useState(0)
+ const [documents, setDocuments] = useState<DocumentWithMemories[]>([])
+ const inputRef = useRef<HTMLInputElement>(null)
+ const listRef = useRef<HTMLDivElement>(null)
+
+ // Get documents from the existing query cache when dialog opens
+ useEffect(() => {
+ if (open) {
+ const queryData = queryClient.getQueryData<{
+ pages: DocumentsResponse[]
+ pageParams: number[]
+ }>(["documents-with-memories", projectId])
+
+ if (queryData?.pages) {
+ setDocuments(queryData.pages.flatMap((page) => page.documents ?? []))
+ }
+ setTimeout(() => inputRef.current?.focus(), 0)
+ setSearch("")
+ setSelectedIndex(0)
+ }
+ }, [open, queryClient, projectId])
+
+ const filteredDocuments = useMemo(() => {
+ if (!search.trim()) return documents
+ const searchLower = search.toLowerCase()
+ return documents.filter((doc) =>
+ doc.title?.toLowerCase().includes(searchLower),
+ )
+ }, [documents, search])
+
+ // Reset selection when filtered results change
+ const handleSearchChange = useCallback((value: string) => {
+ setSearch(value)
+ setSelectedIndex(0)
+ }, [])
+
+ // Scroll selected item into view
+ useEffect(() => {
+ const selectedElement = listRef.current?.querySelector(
+ `[data-index="${selectedIndex}"]`,
+ )
+ selectedElement?.scrollIntoView({ block: "nearest" })
+ }, [selectedIndex])
+
+ const handleSelect = useCallback(
+ (document: DocumentWithMemories) => {
+ if (!document.id) return
+ onOpenDocument(document)
+ onOpenChange(false)
+ setSearch("")
+ },
+ [onOpenDocument, onOpenChange],
+ )
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault()
+ setSelectedIndex((i) => (i < filteredDocuments.length - 1 ? i + 1 : i))
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault()
+ setSelectedIndex((i) => (i > 0 ? i - 1 : i))
+ } else if (e.key === "Enter") {
+ e.preventDefault()
+ const document = filteredDocuments[selectedIndex]
+ if (document) handleSelect(document)
+ }
+ },
+ [filteredDocuments, selectedIndex, handleSelect],
+ )
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent
+ className={cn(
+ "bg-[#1B1F24] flex flex-col p-0 gap-0 overflow-hidden top-[15%]! translate-y-0! scrollbar-thin border-none shadow-2xl",
+ isMobile
+ ? "w-[calc(100vw-2rem)]! max-w-none! rounded-xl"
+ : "w-[560px]! max-w-[560px]! rounded-xl",
+ dmSansClassName(),
+ )}
+ style={{
+ background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
+ boxShadow: "0px 1.5px 20px 0px rgba(0,0,0,0.65)",
+ }}
+ showCloseButton={false}
+ onKeyDown={handleKeyDown}
+ >
+ <DialogTitle className="sr-only">Search Documents</DialogTitle>
+
+ <div
+ id="search-input-container"
+ className="flex items-center gap-3 px-4 py-3"
+ >
+ <SearchIcon className="size-4 text-[#737373] shrink-0" />
+ <input
+ ref={inputRef}
+ type="text"
+ placeholder="Search documents by title..."
+ value={search}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className={cn(
+ "flex-1 bg-transparent text-white text-sm placeholder:text-[#737373] outline-none",
+ dmSansClassName(),
+ )}
+ />
+ </div>
+
+ <div
+ ref={listRef}
+ id="search-results"
+ className="flex flex-col min-h-[300px] max-h-[400px] overflow-y-auto py-1.5 px-1.5"
+ >
+ {filteredDocuments.length === 0 ? (
+ <div className="flex items-center justify-center py-12">
+ <p className="text-[#737373] text-sm">No documents found</p>
+ </div>
+ ) : (
+ filteredDocuments.map((doc, index) => {
+ const isSelected = index === selectedIndex
+ return (
+ <button
+ key={doc.id}
+ type="button"
+ data-index={index}
+ onClick={() => handleSelect(doc)}
+ onMouseEnter={() => setSelectedIndex(index)}
+ className={cn(
+ "flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-left transition-colors",
+ isSelected
+ ? "bg-[#293952]/40"
+ : "opacity-70 hover:opacity-100 hover:bg-[#293952]/40",
+ )}
+ >
+ <div
+ className="flex items-center justify-center size-5 rounded-md shrink-0"
+ style={{
+ background:
+ "linear-gradient(180deg, #14161A 0%, #0D0F12 100%)",
+ boxShadow:
+ "inset 0px 1px 1px rgba(255,255,255,0.03), inset 0px -1px 1px rgba(0,0,0,0.1)",
+ }}
+ >
+ <DocumentIcon
+ type={doc.type}
+ url={doc.url}
+ className="size-4"
+ />
+ </div>
+ <div className="flex-1 min-w-0 flex gap-1 justify-between items-center">
+ <p className="text-sm font-medium text-white truncate">
+ {doc.title || "Untitled"}
+ </p>
+ <p className="text-xs text-[#737373] text-nowrap">
+ {new Date(doc.createdAt).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })}
+ </p>
+ </div>
+ </button>
+ )
+ })
+ )}
+ </div>
+
+ <div
+ id="search-footer"
+ className="flex items-center justify-between px-4 py-2.5 text-[11px] text-[#737373]"
+ >
+ <div className="flex items-center gap-4">
+ <span className="flex items-center gap-1.5">
+ <span className="flex gap-0.5">
+ <kbd className="px-1 py-0.5 rounded bg-[#14161A] border border-[#2E3033] text-[10px] font-medium">
+ ↑
+ </kbd>
+ <kbd className="px-1 py-0.5 rounded bg-[#14161A] border border-[#2E3033] text-[10px] font-medium">
+ ↓
+ </kbd>
+ </span>
+ <span>Navigate</span>
+ </span>
+ <span className="flex items-center gap-1.5">
+ <kbd className="px-1.5 py-0.5 rounded bg-[#14161A] border border-[#2E3033] text-[10px] font-medium">
+ ↵
+ </kbd>
+ <span>Open</span>
+ </span>
+ <span className="flex items-center gap-1.5">
+ <kbd className="px-1.5 py-0.5 rounded bg-[#14161A] border border-[#2E3033] text-[10px] font-medium">
+ Esc
+ </kbd>
+ <span>Close</span>
+ </span>
+ </div>
+ <span>{filteredDocuments.length} documents</span>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/apps/web/components/new/header.tsx b/apps/web/components/new/header.tsx
index 0a88459d..9691f733 100644
--- a/apps/web/components/new/header.tsx
+++ b/apps/web/components/new/header.tsx
@@ -39,9 +39,15 @@ interface HeaderProps {
onAddMemory?: () => void
onOpenMCP?: () => void
onOpenChat?: () => void
+ onOpenSearch?: () => void
}
-export function Header({ onAddMemory, onOpenMCP, onOpenChat }: HeaderProps) {
+export function Header({
+ onAddMemory,
+ onOpenMCP,
+ onOpenChat,
+ onOpenSearch,
+}: HeaderProps) {
const { user } = useAuth()
const { selectedProject } = useProject()
const { switchProject } = useProjectMutations()
@@ -247,6 +253,7 @@ export function Header({ onAddMemory, onOpenMCP, onOpenChat }: HeaderProps) {
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
+ onClick={onOpenSearch}
>
<SearchIcon className="size-4" />
<span className="bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm text-[10px] flex items-center justify-center gap-0.5 px-1">
@@ -256,7 +263,7 @@ export function Header({ onAddMemory, onOpenMCP, onOpenChat }: HeaderProps) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
- <title>Search Icon</title>
+ <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"
diff --git a/apps/web/components/new/mcp-modal/index.tsx b/apps/web/components/new/mcp-modal/index.tsx
index 08fa15e0..69137495 100644
--- a/apps/web/components/new/mcp-modal/index.tsx
+++ b/apps/web/components/new/mcp-modal/index.tsx
@@ -22,7 +22,7 @@ export function MCPModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className={cn(
- "w-[80%]! max-w-[900px]! h-[80%]! max-h-[375px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-3 rounded-[22px]",
+ "w-[90vw]! max-w-[900px]! max-h-[min(75vh,560px)]! border-none bg-[#1B1F24] flex flex-col p-4 gap-3 rounded-[22px]",
dmSansClassName(),
)}
style={{
@@ -37,7 +37,7 @@ export function MCPModal({
Connect your AI to Supermemory
</p>
<p className={cn("text-[#737373] font-medium")}>
- Let your AI create and use your memories via MCP. Learn more
+ Let your AI create and use your memories via MCP.
</p>
</div>
<div className="flex items-center gap-2">
@@ -50,7 +50,7 @@ export function MCPModal({
</DialogPrimitive.Close>
</div>
</div>
- <div className="w-full px-4 py-4 rounded-[14px] bg-[#14161A] shadow-inside-out overflow-y-auto">
+ <div className="w-full flex-1 min-h-0 px-4 py-4 rounded-[14px] bg-[#14161A] shadow-inside-out overflow-y-auto">
<MCPSteps variant="embedded" />
</div>
<DialogFooter className="justify-between!">
diff --git a/apps/web/components/new/mcp-modal/mcp-detail-view.tsx b/apps/web/components/new/mcp-modal/mcp-detail-view.tsx
index 5cc2fa68..12ba0f7e 100644
--- a/apps/web/components/new/mcp-modal/mcp-detail-view.tsx
+++ b/apps/web/components/new/mcp-modal/mcp-detail-view.tsx
@@ -50,7 +50,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
function generateInstallCommand() {
if (!selectedClient) return ""
- let command = `npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${selectedClient} --oauth=yes`
+ let command = `npx -y install-mcp@latest https://mcp.supermemory.ai/mcp --client ${selectedClient} --oauth=yes`
const projectIdForCommand = selectedProject.replace(/^sm_project_/, "")
command += ` --project ${projectIdForCommand}`
diff --git a/apps/web/components/new/memories-grid.tsx b/apps/web/components/new/memories-grid.tsx
index dd4a4e9b..a28d7934 100644
--- a/apps/web/components/new/memories-grid.tsx
+++ b/apps/web/components/new/memories-grid.tsx
@@ -4,7 +4,7 @@ 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 { useCallback, memo, useMemo, useState, useRef } from "react"
+import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react"
import type { z } from "zod"
import { Masonry, useInfiniteLoader } from "masonic"
import { dmSansClassName } from "@/lib/fonts"
@@ -23,22 +23,32 @@ import { YoutubePreview } from "./document-cards/youtube-preview"
import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils"
import { SyncLogoIcon } from "@ui/assets/icons"
import { McpPreview } from "./document-cards/mcp-preview"
-import { DocumentModal } from "./document-modal"
+import { getFaviconUrl } from "@/lib/url-helpers"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
+type OgData = {
+ title?: string
+ image?: string
+}
+
const IS_DEV = process.env.NODE_ENV === "development"
const PAGE_SIZE = IS_DEV ? 100 : 100
const MAX_TOTAL = 1000
-export function MemoriesGrid({ isChatOpen }: { isChatOpen: boolean }) {
+interface MemoriesGridProps {
+ isChatOpen: boolean
+ onOpenDocument: (document: DocumentWithMemories) => void
+}
+
+export function MemoriesGrid({
+ isChatOpen,
+ onOpenDocument,
+}: MemoriesGridProps) {
const { user } = useAuth()
const { selectedProject } = useProject()
const isMobile = useIsMobile()
- const [selectedDocument, setSelectedDocument] =
- useState<DocumentWithMemories | null>(null)
- const [isModalOpen, setIsModalOpen] = useState(false)
const {
data,
@@ -114,10 +124,12 @@ export function MemoriesGrid({ isChatOpen }: { isChatOpen: boolean }) {
},
)
- const handleCardClick = useCallback((document: DocumentWithMemories) => {
- setSelectedDocument(document)
- setIsModalOpen(true)
- }, [])
+ const handleCardClick = useCallback(
+ (document: DocumentWithMemories) => {
+ onOpenDocument(document)
+ },
+ [onOpenDocument],
+ )
const renderDocumentCard = useCallback(
({
@@ -198,11 +210,6 @@ export function MemoriesGrid({ isChatOpen }: { isChatOpen: boolean }) {
)}
</div>
)}
- <DocumentModal
- document={selectedDocument}
- isOpen={isModalOpen}
- onClose={() => setIsModalOpen(false)}
- />
</div>
)
}
@@ -252,6 +259,45 @@ const DocumentCard = memo(
}) => {
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
const cardRef = useRef<HTMLButtonElement>(null)
+ const [ogData, setOgData] = useState<OgData | null>(null)
+ const [isLoadingOg, setIsLoadingOg] = useState(false)
+
+ const ogImage = (document as DocumentWithMemories & { ogImage?: string })
+ .ogImage
+ const needsOgData =
+ document.url &&
+ !document.url.includes("x.com") &&
+ !document.url.includes("twitter.com") &&
+ !document.url.includes("files.supermemory.ai") &&
+ !document.url.includes("docs.googleapis.com") &&
+ (!document.title || !ogImage)
+
+ const hideURL = document.url?.includes("docs.googleapis.com")
+
+ useEffect(() => {
+ if (needsOgData && !ogData && !isLoadingOg && document.url) {
+ setIsLoadingOg(true)
+ fetch(`/api/og?url=${encodeURIComponent(document.url)}`)
+ .then((res) => {
+ if (!res.ok) return null
+ return res.json()
+ })
+ .then((data) => {
+ if (data) {
+ setOgData({
+ title: data.title,
+ image: data.image,
+ })
+ }
+ })
+ .catch(() => {
+ // Silently fail if OG fetch fails
+ })
+ .finally(() => {
+ setIsLoadingOg(false)
+ })
+ }
+ }, [needsOgData, ogData, isLoadingOg, document.url])
const handleMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!cardRef.current) return
@@ -297,7 +343,7 @@ const DocumentCard = memo(
transformStyle: "preserve-3d",
}}
>
- <ContentPreview document={document} />
+ <ContentPreview document={document} ogData={ogData} />
{!(
document.type === "image" ||
document.metadata?.mimeType?.toString().startsWith("image/")
@@ -308,16 +354,28 @@ const DocumentCard = memo(
!document.url.includes("twitter.com") &&
!document.url.includes("files.supermemory.ai") && (
<div className="px-3">
- <p
- className={cn(
- dmSansClassName(),
- "text-[12px] text-[#E5E5E5] line-clamp-1 font-semibold",
+ <div className="flex justify-between items-center gap-2">
+ <p
+ className={cn(
+ dmSansClassName(),
+ "text-[12px] text-[#E5E5E5] line-clamp-1 font-semibold",
+ )}
+ >
+ {document.title || ogData?.title || "Untitled Document"}
+ </p>
+ {getFaviconUrl(document.url) && needsOgData && (
+ <img
+ src={getFaviconUrl(document.url) || ""}
+ alt=""
+ className="w-4 h-4 shrink-0 rounded-lg"
+ onError={(e) => {
+ e.currentTarget.style.display = "none"
+ }}
+ />
)}
- >
- {document.title}
- </p>
+ </div>
- <DocumentUrlDisplay url={document.url} />
+ {!hideURL && <DocumentUrlDisplay url={document.url} />}
</div>
)}
<div
@@ -372,7 +430,13 @@ const DocumentCard = memo(
DocumentCard.displayName = "DocumentCard"
-function ContentPreview({ document }: { document: DocumentWithMemories }) {
+function ContentPreview({
+ document,
+ ogData,
+}: {
+ document: DocumentWithMemories
+ ogData?: OgData | null
+}) {
if (
document.url?.includes("https://docs.googleapis.com/v1/documents") ||
document.url?.includes("docs.google.com/document") ||
@@ -412,7 +476,7 @@ function ContentPreview({ document }: { document: DocumentWithMemories }) {
}
if (document.url?.includes("https://")) {
- return <WebsitePreview document={document} />
+ return <WebsitePreview document={document} ogData={ogData} />
}
// Default to Note
diff --git a/apps/web/lib/url-helpers.ts b/apps/web/lib/url-helpers.ts
index e4147a05..d171a6c2 100644
--- a/apps/web/lib/url-helpers.ts
+++ b/apps/web/lib/url-helpers.ts
@@ -188,3 +188,49 @@ export function toLinkedInProfileUrl(handle: string): string {
if (!handle.trim()) return ""
return `https://linkedin.com/in/${handle.trim()}`
}
+
+/**
+ * Gets the favicon URL for a given URL.
+ */
+export function getFaviconUrl(url: string | null | undefined): string | null {
+ if (!url) return null
+ try {
+ const urlObj = new URL(url)
+ return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=16`
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Extracts the document ID from a Google Docs/Sheets/Slides URL.
+ * Works with various URL formats:
+ * - https://docs.google.com/document/d/{id}/edit
+ * - https://docs.google.com/spreadsheets/d/{id}/edit#gid=0
+ * - https://docs.google.com/presentation/d/{id}/edit
+ */
+export function extractGoogleDocId(url: string): string | null {
+ try {
+ const match = url.match(/\/d\/([a-zA-Z0-9_-]+)/)
+ return match?.[1] ?? null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Generates the embed URL for a Google document based on its type.
+ */
+export function getGoogleEmbedUrl(
+ docId: string,
+ type: "google_doc" | "google_sheet" | "google_slide",
+): string {
+ switch (type) {
+ case "google_doc":
+ return `https://docs.google.com/document/d/${docId}/preview`
+ case "google_sheet":
+ return `https://docs.google.com/spreadsheets/d/${docId}/preview`
+ case "google_slide":
+ return `https://docs.google.com/presentation/d/${docId}/embed?start=false&loop=false&delayms=3000`
+ }
+}
diff --git a/packages/ui/components/sonner.tsx b/packages/ui/components/sonner.tsx
index 6a984710..adb68ea8 100644
--- a/packages/ui/components/sonner.tsx
+++ b/packages/ui/components/sonner.tsx
@@ -10,15 +10,14 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
className="toaster group"
theme={theme as ToasterProps["theme"]}
- closeButton
toastOptions={{
classNames: {
toast:
- "!bg-[#0b1017] !border !border-[#1b1f24] !rounded-[10px] !p-3 !shadow-lg",
+ "!bg-[#0b1017] !border !border-[#1b1f24] !rounded-[10px] !p-3 !shadow-lg px-4",
title:
"!text-[#fafafa] !text-[12px] !leading-[1.35] !tracking-[-0.12px] !font-['DM_Sans',sans-serif]",
description:
- "!text-[#fafafa] !text-[12px] !leading-[1.35] !tracking-[-0.12px] !font-['DM_Sans',sans-serif]",
+ "!text-[#fafafa] !text-[12px] !leading-[1.35] !tracking-[-0.12px] !font-['DM_Sans',sans-serif] opacity-80",
closeButton:
"!bg-transparent !border-none !text-[#fafafa] hover:!bg-white/10 !size-6 !static !ml-2 !shrink-0",
actionButton: "!bg-white/10 !text-[#fafafa]",