- {!isMobile && (hasQuickNote || hasHighlights) && (
-
- {hasQuickNote && quickNoteProps && (
-
-
-
- )}
- {hasHighlights && highlightsProps && (
-
-
-
- )}
-
-
-
-
+ {localViewMode === "timeline" ? (
+
+ ) : (
+
)}
-
-
- {isLoadingMore && (
+
+ {isLoadingMore && localViewMode === "grid" && (
@@ -676,7 +757,7 @@ function DocumentUrlDisplay({ url }: { url: string }) {
{isLoading ? "YouTube" : channelName || "YouTube"}
@@ -688,7 +769,7 @@ function DocumentUrlDisplay({ url }: { url: string }) {
{getAbsoluteUrl(url)}
@@ -701,6 +782,74 @@ function isTemporaryId(id: string | null | undefined): boolean {
return id.startsWith("temp-") || id.startsWith("temp-file-")
}
+const PROCESSING_WORDS = [
+ "Reading",
+ "Absorbing",
+ "Scanning",
+ "Thinking",
+ "Connecting",
+ "Pondering",
+ "Synthesizing",
+ "Reflecting",
+ "Understanding",
+ "Organizing",
+ "Memorizing",
+ "Filing",
+ "Saving",
+ "Learning",
+ "Cataloguing",
+ "Weaving",
+]
+
+function ProcessingBadge() {
+ const [wordIndex, setWordIndex] = useState(() =>
+ Math.floor(Math.random() * PROCESSING_WORDS.length),
+ )
+
+ useEffect(() => {
+ const id = setInterval(() => {
+ setWordIndex((i) => (i + 1) % PROCESSING_WORDS.length)
+ }, 1800)
+ return () => clearInterval(id)
+ }, [])
+
+ return (
+
+
+
+
+
+
+ {PROCESSING_WORDS[wordIndex]}
+
+
+ )
+}
+
+function DoneBadge() {
+ return (
+
+
+
+ Done
+
+
+ )
+}
+
const DocumentCard = memo(
({
index: _index,
@@ -710,6 +859,7 @@ const DocumentCard = memo(
isSelectionMode = false,
isSelected = false,
onToggleSelection,
+ processingStatus,
}: {
index: number
data: DocumentWithMemories
@@ -718,12 +868,26 @@ const DocumentCard = memo(
isSelectionMode?: boolean
isSelected?: boolean
onToggleSelection?: () => void
+ processingStatus?: string
}) => {
const canSelect =
!isTemporaryId(document.id) && !isTemporaryId(document.customId)
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
const cardRef = useRef
(null)
const [ogData, setOgData] = useState(null)
+ const [showDone, setShowDone] = useState(false)
+ const prevStatusRef = useRef(processingStatus)
+
+ useEffect(() => {
+ const prev = prevStatusRef.current
+ prevStatusRef.current = processingStatus
+ // Show the "done" checkmark briefly when the card leaves the processing map
+ if (prev && !processingStatus) {
+ setShowDone(true)
+ const id = setTimeout(() => setShowDone(false), 2000)
+ return () => clearTimeout(id)
+ }
+ }, [processingStatus])
const ogImage = (document as DocumentWithMemories & { ogImage?: string })
.ogImage
@@ -847,15 +1011,16 @@ const DocumentCard = memo(
) && (
{document.url &&
- !document.url.includes("x.com") &&
- !document.url.includes("twitter.com") &&
- !document.url.includes("files.supermemory.ai") && (
+ !document.url.includes("files.supermemory.ai") &&
+ (document.title ||
+ (!document.url.includes("x.com") &&
+ !document.url.includes("twitter.com"))) && (
{document.title || ogData?.title || "Untitled Document"}
@@ -878,16 +1043,22 @@ const DocumentCard = memo(
0
+ processingStatus ||
+ showDone ||
+ document.memoryEntries.length > 0
? "justify-between"
: "justify-end",
)}
>
- {document.memoryEntries.length > 0 && (
+ {processingStatus ? (
+
+ ) : showDone ? (
+
+ ) : document.memoryEntries.length > 0 ? (
{document.memoryEntries.length}
- )}
+ ) : null}
{new Date(document.createdAt).toLocaleDateString("en-US", {
@@ -939,10 +1110,7 @@ function ContentPreview({
return
}
- if (
- document.url?.includes("x.com/") &&
- document.metadata?.sm_internal_twitter_metadata
- ) {
+ if (document.metadata?.sm_internal_twitter_metadata) {
return (
+ }
+
if (document.source === "mcp") {
return
}
diff --git a/apps/web/components/memory-graph/graph-card.tsx b/apps/web/components/memory-graph/graph-card.tsx
index d6837521b..807f594b1 100644
--- a/apps/web/components/memory-graph/graph-card.tsx
+++ b/apps/web/components/memory-graph/graph-card.tsx
@@ -23,7 +23,7 @@ function seededRandom(seed: number) {
}
}
-function StaticGraphPreview({
+export function StaticGraphPreview({
documentCount,
memoryCount,
width,
@@ -70,10 +70,10 @@ function StaticGraphPreview({
let b = Math.floor(rand() * nodes.length)
if (b === a) b = (a + 1) % nodes.length
result.push({
- x1: nodes[a]!.x,
- y1: nodes[a]!.y,
- x2: nodes[b]!.x,
- y2: nodes[b]!.y,
+ x1: nodes[a]?.x,
+ y1: nodes[a]?.y,
+ x2: nodes[b]?.x,
+ y2: nodes[b]?.y,
})
}
return result
diff --git a/apps/web/components/onboarding/setup/integrations-step.tsx b/apps/web/components/onboarding/setup/integrations-step.tsx
index d7f16e517..40a856a23 100644
--- a/apps/web/components/onboarding/setup/integrations-step.tsx
+++ b/apps/web/components/onboarding/setup/integrations-step.tsx
@@ -171,14 +171,7 @@ export function IntegrationsStep() {
})}
-
-
router.push("/onboarding/setup?step=relatable")}
- >
- ← Back
-
+
([])
-
- const handleContinueOrSkip = () => {
- const selectedTexts = selectedOptions.map(
- (idx) => relatableOptions[idx]?.text || "",
- )
- analytics.onboardingRelatableSelected({ options: selectedTexts })
- router.push("/onboarding/setup?step=integrations")
- }
-
- return (
-
-
- Which of these sound most relatable?
-
-
-
- {relatableOptions.map((option, index) => (
-
-
{
- setSelectedOptions((prev) =>
- prev.includes(index)
- ? prev.filter((i) => i !== index)
- : [...prev, index],
- )
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault()
- setSelectedOptions((prev) =>
- prev.includes(index)
- ? prev.filter((i) => i !== index)
- : [...prev, index],
- )
- }
- }}
- type="button"
- >
-
- {selectedOptions.includes(index) && (
-
- )}
-
-
-
- {option.emoji}
-
-
- {option.text}
-
-
-
-
- ))}
-
-
-
-
- {selectedOptions.length === 0
- ? "Skip for now →"
- : "Remember this →"}
-
-
-
-
- )
-}
diff --git a/apps/web/components/onboarding/welcome/continue-step.tsx b/apps/web/components/onboarding/welcome/continue-step.tsx
index 15bddeb50..1b8fe91b1 100644
--- a/apps/web/components/onboarding/welcome/continue-step.tsx
+++ b/apps/web/components/onboarding/welcome/continue-step.tsx
@@ -42,11 +42,11 @@ export function OnboardingContentStep({
const router = useRouter()
const handleContinue = () => {
- router.push("/onboarding/welcome?step=features")
+ router.push("/old/onboarding/welcome?step=features")
}
const handleAddMemories = () => {
- router.push("/onboarding/welcome?step=memories")
+ router.push("/old/onboarding/welcome?step=memories")
}
const isContinue = currentView === "continue"
diff --git a/apps/web/components/onboarding/welcome/input-step.tsx b/apps/web/components/onboarding/welcome/input-step.tsx
index 7cb7fdb33..f89156c85 100644
--- a/apps/web/components/onboarding/welcome/input-step.tsx
+++ b/apps/web/components/onboarding/welcome/input-step.tsx
@@ -23,28 +23,10 @@ export function InputStep({
isSubmitting && "pointer-events-none",
)}
style={{ gap: "24px" }}
- initial={{
- opacity: 0,
- y: 10,
- }}
- animate={{
- opacity: 1,
- y: 0,
- }}
- exit={{
- opacity: 0,
- y: -10,
- transition: {
- duration: 0.5,
- ease: "easeOut",
- bounce: 0,
- },
- }}
- transition={{
- duration: 0.8,
- ease: "easeOut",
- delay: 1,
- }}
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ exit={{ opacity: 0, transition: { duration: 0.3, ease: "easeOut" } }}
+ transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
layout
>
diff --git a/apps/web/components/onboarding/welcome/profile-step.tsx b/apps/web/components/onboarding/welcome/profile-step.tsx
index 6e91ebd9d..adaa450fe 100644
--- a/apps/web/components/onboarding/welcome/profile-step.tsx
+++ b/apps/web/components/onboarding/welcome/profile-step.tsx
@@ -256,7 +256,7 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) {
description_length: description.trim().length,
})
onSubmit(formData)
- router.push("/onboarding/setup?step=relatable")
+ router.push("/old/onboarding/setup?step=integrations")
}}
>
{isSubmitting ? "Fetching..." : "Remember this →"}
diff --git a/apps/web/components/space-selector.tsx b/apps/web/components/space-selector.tsx
index 64c334b07..e8e2e68ce 100644
--- a/apps/web/components/space-selector.tsx
+++ b/apps/web/components/space-selector.tsx
@@ -4,15 +4,7 @@ import { useState, useMemo } from "react"
import { cn } from "@lib/utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
-import {
- ChevronsLeftRight,
- Plus,
- Trash2,
- XIcon,
- Loader2,
- Globe,
- Layers,
-} from "lucide-react"
+import { ChevronDown, Plus, Trash2, XIcon, Loader2, Layers } from "lucide-react"
import type { ContainerTagListType } from "@lib/types"
import { AddSpaceModal } from "./add-space-modal"
import { SelectSpacesModal } from "./select-spaces-modal"
@@ -57,8 +49,13 @@ export interface SpaceSelectorProps {
}
const triggerVariants = {
- default: "px-3 py-2 rounded-md hover:bg-white/5",
- insideOut: "px-3 py-2 rounded-full bg-[#0D121A] shadow-inside-out",
+ default:
+ "h-10 min-h-10 shrink-0 rounded-full border border-[#161F2C] bg-muted px-3 gap-2 " +
+ "hover:bg-white/5 " +
+ "data-[state=open]:border-[#2261CA33] data-[state=open]:bg-[#00173C]/35 " +
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2261CA33]/35",
+ insideOut:
+ "h-10 min-h-10 gap-2 px-3 rounded-full bg-[#0D121A] shadow-inside-out hover:bg-[#121820]",
}
export function SpaceSelector({
@@ -90,15 +87,9 @@ export function SpaceSelector({
const { deleteProjectMutation } = useProjectMutations()
- const { allProjects, novaProjects, isLoading } = useContainerTags()
-
- const isNovaSpaces = selectedProjects.length === 0
+ const { allProjects, isLoading } = useContainerTags()
const displayInfo = useMemo(() => {
- if (isNovaSpaces) {
- return { name: "Nova Spaces", emoji: null, isMultiple: false }
- }
-
if (selectedProjects.length === 1) {
const containerTag = selectedProjects[0]
if (containerTag === DEFAULT_PROJECT_ID) {
@@ -114,18 +105,17 @@ export function SpaceSelector({
}
}
- return {
- name: `${selectedProjects.length} spaces`,
- emoji: null,
- isMultiple: true,
+ if (selectedProjects.length > 1) {
+ return {
+ name: `${selectedProjects.length} spaces`,
+ emoji: null,
+ isMultiple: true,
+ }
}
- }, [allProjects, selectedProjects, isNovaSpaces])
- const handleSelectNovaSpaces = () => {
- analytics.spaceSwitched({ space_id: "nova_spaces" })
- onValueChange([]) // Empty array = "Nova Spaces" (all nova)
- setIsOpen(false)
- }
+ // Nothing selected — default to "My Space"
+ return { name: "My Space", emoji: "📁", isMultiple: false }
+ }, [allProjects, selectedProjects])
const handleSelectSingleSpace = (containerTag: string) => {
analytics.spaceSwitched({ space_id: containerTag })
@@ -204,13 +194,13 @@ export function SpaceSelector({
}
const availableTargetProjects = useMemo(() => {
- const filtered = novaProjects.filter(
+ const filtered = allProjects.filter(
(p: ContainerTagListType) =>
p.id !== deleteDialog.project?.id &&
p.containerTag !== deleteDialog.project?.containerTag,
)
- const defaultProject = novaProjects.find(
+ const defaultProject = allProjects.find(
(p: ContainerTagListType) => p.containerTag === DEFAULT_PROJECT_ID,
)
@@ -227,7 +217,7 @@ export function SpaceSelector({
}
return filtered
- }, [novaProjects, deleteDialog.project])
+ }, [allProjects, deleteDialog.project])
return (
<>
@@ -235,29 +225,60 @@ export function SpaceSelector({
- {isNovaSpaces ? (
-
- ) : displayInfo.isMultiple ? (
-
+ {displayInfo.isMultiple ? (
+
) : (
-
+
{displayInfo.emoji}
)}
{!compact && (
-
- {isLoading ? "..." : displayInfo.name}
+
+ {isLoading ? "…" : displayInfo.name}
+
+ )}
+ {compact && (
+
+ {isLoading ? "Loading" : displayInfo.name}
)}
{showChevron && (
-
+
)}
@@ -274,25 +295,6 @@ export function SpaceSelector({
>
- {!singleSelect && (
- <>
-
-
- Nova Spaces
-
-
-
- >
- )}
-
My Spaces
@@ -313,7 +315,7 @@ export function SpaceSelector({
My Space
- {novaProjects
+ {allProjects
.filter(
(p: ContainerTagListType) =>
p.containerTag !== DEFAULT_PROJECT_ID,
diff --git a/apps/web/components/timeline-view.tsx b/apps/web/components/timeline-view.tsx
new file mode 100644
index 000000000..5a8d957d9
--- /dev/null
+++ b/apps/web/components/timeline-view.tsx
@@ -0,0 +1,429 @@
+"use client"
+
+import { useState, useEffect, useRef, useCallback } from "react"
+import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
+import type { z } from "zod"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import { SyncLogoIcon } from "@ui/assets/icons"
+import { DocumentIcon } from "@/components/document-icon"
+import { ChevronDownIcon } from "lucide-react"
+
+type DocumentsResponse = z.infer
+type DocumentWithMemories = DocumentsResponse["documents"][0]
+
+// ─── Time period helpers ─────────────────────────────────────────────────────
+
+function getTimePeriodLabel(date: Date, now: Date): string {
+ const docDay = new Date(date.getFullYear(), date.getMonth(), date.getDate())
+ const todayDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
+ const diffDays = Math.round(
+ (todayDay.getTime() - docDay.getTime()) / 86400000,
+ )
+
+ if (diffDays === 0) return "Today"
+ if (diffDays === 1) return "Yesterday"
+ if (diffDays < 7) return date.toLocaleDateString("en-US", { weekday: "long" })
+ if (date.getFullYear() === now.getFullYear())
+ return date.toLocaleDateString("en-US", { month: "long", day: "numeric" })
+ return date.toLocaleDateString("en-US", {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ })
+}
+
+// ─── Document type helpers ────────────────────────────────────────────────────
+
+type CategoryInfo = { label: string; singularLabel: string; key: string }
+
+function getDocumentTypeInfo(doc: DocumentWithMemories): CategoryInfo {
+ if (doc.source === "mcp")
+ return { label: "MCP Items", singularLabel: "MCP Item", key: "mcp" }
+ if (doc.url?.includes("youtube.com") || doc.url?.includes("youtu.be"))
+ return {
+ label: "YouTube Videos",
+ singularLabel: "YouTube Video",
+ key: "youtube",
+ }
+ switch (doc.type) {
+ case "tweet":
+ return { label: "Tweets", singularLabel: "Tweet", key: "tweet" }
+ case "google_doc":
+ return {
+ label: "Google Docs",
+ singularLabel: "Google Doc",
+ key: "google_doc",
+ }
+ case "google_slide":
+ return {
+ label: "Google Slides",
+ singularLabel: "Google Slide",
+ key: "google_slide",
+ }
+ case "google_sheet":
+ return {
+ label: "Google Sheets",
+ singularLabel: "Google Sheet",
+ key: "google_sheet",
+ }
+ case "notion_doc":
+ return {
+ label: "Notion Docs",
+ singularLabel: "Notion Doc",
+ key: "notion_doc",
+ }
+ case "text":
+ return { label: "Notes", singularLabel: "Note", key: "text" }
+ case "pdf":
+ return { label: "PDFs", singularLabel: "PDF", key: "pdf" }
+ case "image":
+ return { label: "Images", singularLabel: "Image", key: "image" }
+ case "video":
+ return { label: "Videos", singularLabel: "Video", key: "video" }
+ case "onedrive":
+ return {
+ label: "OneDrive Files",
+ singularLabel: "OneDrive File",
+ key: "onedrive",
+ }
+ case "webpage":
+ return { label: "Web Pages", singularLabel: "Web Page", key: "webpage" }
+ default:
+ return doc.url?.startsWith("https://")
+ ? { label: "Web Pages", singularLabel: "Web Page", key: "webpage" }
+ : { label: "Notes", singularLabel: "Note", key: "text" }
+ }
+}
+
+function getPreviewText(doc: DocumentWithMemories): string {
+ return doc.summary || doc.content || doc.title || ""
+}
+
+// ─── Grouped data structures ─────────────────────────────────────────────────
+
+type TypeGroup = { categoryInfo: CategoryInfo; docs: DocumentWithMemories[] }
+type PeriodGroup = { label: string; typeGroups: TypeGroup[] }
+
+function groupDocuments(
+ documents: DocumentWithMemories[],
+ now: Date,
+): PeriodGroup[] {
+ const sorted = [...documents].sort(
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+ )
+
+ const periodMap = new Map()
+ const periodOrder: string[] = []
+
+ for (const doc of sorted) {
+ const label = getTimePeriodLabel(new Date(doc.createdAt), now)
+ if (!periodMap.has(label)) {
+ periodMap.set(label, [])
+ periodOrder.push(label)
+ }
+ periodMap.get(label)?.push(doc)
+ }
+
+ return periodOrder.map((label) => {
+ const docs = periodMap.get(label)!
+ const categoryMap = new Map<
+ string,
+ { info: CategoryInfo; docs: DocumentWithMemories[] }
+ >()
+ const categoryOrder: string[] = []
+
+ for (const doc of docs) {
+ const info = getDocumentTypeInfo(doc)
+ if (!categoryMap.has(info.key)) {
+ categoryMap.set(info.key, { info, docs: [] })
+ categoryOrder.push(info.key)
+ }
+ categoryMap.get(info.key)?.docs.push(doc)
+ }
+
+ return {
+ label,
+ typeGroups: categoryOrder.map((key) => {
+ const entry = categoryMap.get(key)!
+ return { categoryInfo: entry.info, docs: entry.docs }
+ }),
+ }
+ })
+}
+
+// ─── Individual timeline card ─────────────────────────────────────────────────
+
+function TimelineCard({
+ doc,
+ onOpenDocument,
+ indent = false,
+}: {
+ doc: DocumentWithMemories
+ onOpenDocument: (doc: DocumentWithMemories) => void
+ indent?: boolean
+}) {
+ const preview = getPreviewText(doc)
+ const typeLabel = doc.type
+ ? doc.type.charAt(0).toUpperCase() + doc.type.slice(1).replace(/_/g, " ")
+ : "Document"
+ const totalMemories = doc.memoryEntries.length
+
+ return (
+ onOpenDocument(doc)}
+ >
+ {/* Type label */}
+
+
+
+ {typeLabel}
+
+
+
+ {/* Title */}
+ {doc.title && (
+
+ {doc.title}
+
+ )}
+
+ {/* Preview */}
+ {preview && (
+
+ {preview}
+
+ )}
+
+ {/* Footer */}
+ {totalMemories > 0 && (
+
+
+
+ {totalMemories}
+
+
+ )}
+
+ )
+}
+
+// ─── Collapsed group card ─────────────────────────────────────────────────────
+
+function GroupCard({
+ group,
+ isExpanded,
+ onToggle,
+ onOpenDocument,
+ expandKey,
+}: {
+ group: TypeGroup
+ isExpanded: boolean
+ onToggle: () => void
+ onOpenDocument: (doc: DocumentWithMemories) => void
+ expandKey: string
+}) {
+ const firstDoc = group.docs[0]!
+ const preview = getPreviewText(firstDoc)
+ const count = group.docs.length
+ const { label, singularLabel } = group.categoryInfo
+ const countLabel = count === 1 ? `1 ${singularLabel}` : `${count} ${label}`
+ const totalMemories = group.docs.reduce(
+ (sum, d) => sum + d.memoryEntries.length,
+ 0,
+ )
+
+ return (
+
+
+
+
+
+ {countLabel}
+
+ {preview && (
+
+ — {preview}
+
+ )}
+ {totalMemories > 0 && (
+
+ {totalMemories}
+
+ )}
+
+
+
+
+
+ {isExpanded && (
+
+ {group.docs.map((doc) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ─── Main TimelineView ────────────────────────────────────────────────────────
+
+interface TimelineViewProps {
+ documents: DocumentWithMemories[]
+ onOpenDocument: (document: DocumentWithMemories) => void
+ hasNextPage?: boolean
+ isFetchingNextPage?: boolean
+ onLoadMore?: () => void
+}
+
+export function TimelineView({
+ documents,
+ onOpenDocument,
+ hasNextPage,
+ isFetchingNextPage,
+ onLoadMore,
+}: TimelineViewProps) {
+ const [now] = useState(() => new Date())
+ const [expandedGroups, setExpandedGroups] = useState>(new Set())
+ const sentinelRef = useRef(null)
+
+ useEffect(() => {
+ if (!sentinelRef.current || !onLoadMore) return
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) {
+ onLoadMore()
+ }
+ },
+ { threshold: 0.1 },
+ )
+ observer.observe(sentinelRef.current)
+ return () => observer.disconnect()
+ }, [hasNextPage, isFetchingNextPage, onLoadMore])
+
+ const toggleGroup = useCallback((key: string) => {
+ setExpandedGroups((prev) => {
+ const next = new Set(prev)
+ if (next.has(key)) next.delete(key)
+ else next.add(key)
+ return next
+ })
+ }, [])
+
+ const periodGroups = groupDocuments(documents, now)
+
+ return (
+
+ {periodGroups.map((period) => (
+
+
+
+ {period.label}
+
+
+
+
+ {period.typeGroups.map((group) => {
+ const expandKey = `${period.label}::${group.categoryInfo.key}`
+
+ if (group.docs.length === 1) {
+ return (
+
+ )
+ }
+
+ return (
+ toggleGroup(expandKey)}
+ onOpenDocument={onOpenDocument}
+ />
+ )
+ })}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/web/components/user-profile-menu.tsx b/apps/web/components/user-profile-menu.tsx
index 6ffbf108e..b7695c443 100644
--- a/apps/web/components/user-profile-menu.tsx
+++ b/apps/web/components/user-profile-menu.tsx
@@ -1,5 +1,6 @@
"use client"
+import { useCustomer } from "autumn-js/react"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import { useAuth } from "@lib/auth-context"
import {
@@ -11,25 +12,33 @@ import {
} from "@ui/components/dropdown-menu"
import { authClient } from "@lib/auth"
import { useRouter } from "next/navigation"
-import { LogOut, Settings, RotateCcw, HelpCircle } from "lucide-react"
+import { LogOut, Settings, RotateCcw, HelpCircle, LifeBuoy } from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
+import { useTokenUsage } from "@/hooks/use-token-usage"
export function UserProfileMenu({
className,
avatarClassName,
+ onOpenFeedback,
}: {
className?: string
avatarClassName?: string
+ onOpenFeedback?: () => void
}) {
const { user } = useAuth()
const router = useRouter()
const { resetOrgOnboarded } = useOrgOnboarding()
+ const autumn = useCustomer()
+ const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn)
+
+ const planBadgeLabel =
+ currentPlan === "pro" ? "PRO" : currentPlan === "scale" ? "SCALE" : null
const handleTryOnboarding = () => {
resetOrgOnboarded()
- router.push("/onboarding?step=input&flow=welcome")
+ router.push("/onboarding")
}
const handleSignOut = () => {
@@ -45,27 +54,72 @@ export function UserProfileMenu({
if (!user) return null
+ const initials = (() => {
+ if (user.name) {
+ const parts = user.name.trim().split(/\s+/)
+ return parts.length >= 2
+ ? `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
+ : parts[0].slice(0, 2).toUpperCase()
+ }
+ if (user.email) return user.email.slice(0, 2).toUpperCase()
+ return "SM"
+ })()
+
+ const avatarColor = (() => {
+ const palette = [
+ "#0e2244", // navy blue
+ "#1a1a3e", // deep indigo
+ "#1e1030", // dark violet
+ "#0d2e2e", // dark teal
+ "#2a1020", // dark rose
+ "#1a2a10", // deep forest
+ "#2e1a0a", // dark amber
+ "#0a1e2e", // ocean
+ ]
+ const seed = user.email ?? user.name ?? ""
+ let hash = 0
+ for (let i = 0; i < seed.length; i++)
+ hash = seed.charCodeAt(i) + ((hash << 5) - hash)
+ return palette[((hash % palette.length) + palette.length) % palette.length]
+ })()
+
return (
-
- {user.name?.charAt(0)}
+
+ {initials}
+ {!planLoading && planBadgeLabel ? (
+
+ {planBadgeLabel}
+
+ ) : null}
- Restart Onboarding
+ Try onboarding
+ {onOpenFeedback ? (
+
+
+ Feedback
+
+ ) : null}
allProjects.filter((p) => p.isNova),
- [allProjects],
- )
-
- const novaContainerTags = useMemo(
- () => novaProjects.map((p) => p.containerTag),
- [novaProjects],
- )
-
return {
allProjects,
- novaProjects,
- novaContainerTags,
isLoading,
}
}
diff --git a/apps/web/hooks/use-document-mutations.ts b/apps/web/hooks/use-document-mutations.ts
index 6aac8f617..bff873a70 100644
--- a/apps/web/hooks/use-document-mutations.ts
+++ b/apps/web/hooks/use-document-mutations.ts
@@ -293,6 +293,7 @@ export function useDocumentMutations({
description: "Your note is being processed",
})
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
+ queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
onClose?.()
},
})
@@ -356,6 +357,7 @@ export function useDocumentMutations({
description: "Your link is being processed",
})
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
+ queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
onClose?.()
},
})
@@ -499,6 +501,7 @@ export function useDocumentMutations({
analytics.documentAdded({ type: "file", project_id: variables.project })
}
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
+ queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
if (data.failures.length === 0) {
toast.success(
data.successCount === 1
diff --git a/apps/web/hooks/use-personalization.ts b/apps/web/hooks/use-personalization.ts
new file mode 100644
index 000000000..2f3238102
--- /dev/null
+++ b/apps/web/hooks/use-personalization.ts
@@ -0,0 +1,301 @@
+"use client"
+
+import { useState, useEffect, useCallback } from "react"
+import { $fetch } from "@lib/api"
+import type { SearchResult } from "@repo/lib/api"
+
+const CACHE_KEY = "sm_profession_v1"
+const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
+
+export type Profession =
+ | "developer"
+ | "finance"
+ | "research"
+ | "design"
+ | "legal"
+ | "marketing"
+ | "medical"
+ | "default"
+
+export interface PersonalizedCopy {
+ saveLink: string
+ writeNote: string
+ chatPlaceholder: string
+}
+
+const COPY: Record = {
+ developer: {
+ saveLink: "Save a repo",
+ writeNote: "Write dev notes",
+ chatPlaceholder: "Ask about your code, docs, or notes…",
+ },
+ finance: {
+ saveLink: "Save an article",
+ writeNote: "Log a thesis",
+ chatPlaceholder: "Ask about your research or portfolio…",
+ },
+ research: {
+ saveLink: "Save a paper",
+ writeNote: "Write notes",
+ chatPlaceholder: "Ask about your reading or research…",
+ },
+ design: {
+ saveLink: "Save inspiration",
+ writeNote: "Write a brief",
+ chatPlaceholder: "What are you working on today?",
+ },
+ legal: {
+ saveLink: "Save a document",
+ writeNote: "Write a memo",
+ chatPlaceholder: "Ask about your cases or contracts…",
+ },
+ marketing: {
+ saveLink: "Save a resource",
+ writeNote: "Write campaign notes",
+ chatPlaceholder: "Ask about your campaigns or research…",
+ },
+ medical: {
+ saveLink: "Save a study",
+ writeNote: "Write clinical notes",
+ chatPlaceholder: "Ask about your research or cases…",
+ },
+ default: {
+ saveLink: "Save link",
+ writeNote: "Write note",
+ chatPlaceholder: "Ask your supermemory…",
+ },
+}
+
+const KEYWORDS: Record, string[]> = {
+ developer: [
+ "software",
+ "engineer",
+ "developer",
+ "programming",
+ "code",
+ "github",
+ "typescript",
+ "javascript",
+ "python",
+ "backend",
+ "frontend",
+ "api",
+ "repository",
+ "startup",
+ "swe",
+ "tech",
+ "devops",
+ "cloud",
+ ],
+ finance: [
+ "finance",
+ "investment",
+ "portfolio",
+ "trading",
+ "stock",
+ "fund",
+ "equity",
+ "crypto",
+ "banking",
+ "analyst",
+ "fintech",
+ "hedge",
+ "venture",
+ "capital",
+ "asset",
+ "valuation",
+ "economics",
+ ],
+ research: [
+ "research",
+ "academia",
+ "phd",
+ "paper",
+ "journal",
+ "study",
+ "scholar",
+ "university",
+ "professor",
+ "scientist",
+ "thesis",
+ "experiment",
+ "hypothesis",
+ "data analysis",
+ "publication",
+ ],
+ design: [
+ "design",
+ "ux",
+ "ui",
+ "figma",
+ "creative",
+ "visual",
+ "brand",
+ "illustrator",
+ "adobe",
+ "typography",
+ "wireframe",
+ "prototype",
+ "product design",
+ "graphic",
+ "art director",
+ ],
+ legal: [
+ "lawyer",
+ "attorney",
+ "legal",
+ "law",
+ "contract",
+ "compliance",
+ "litigation",
+ "counsel",
+ "paralegal",
+ "court",
+ "regulatory",
+ "intellectual property",
+ "patent",
+ "trademark",
+ ],
+ marketing: [
+ "marketing",
+ "growth",
+ "seo",
+ "content",
+ "campaign",
+ "brand",
+ "advertising",
+ "social media",
+ "pr",
+ "communications",
+ "copywriting",
+ "conversion",
+ "analytics",
+ "inbound",
+ ],
+ medical: [
+ "doctor",
+ "physician",
+ "medical",
+ "healthcare",
+ "clinical",
+ "hospital",
+ "nursing",
+ "surgery",
+ "patient",
+ "medicine",
+ "diagnosis",
+ "treatment",
+ "pharmacology",
+ "dentist",
+ ],
+}
+
+function classifyProfession(results: SearchResult[]): Profession {
+ const text = results
+ .flatMap((r) => [
+ r.title ?? "",
+ r.summary ?? "",
+ ...(r.chunks?.slice(0, 2).map((c) => c.content) ?? []),
+ ])
+ .join(" ")
+ .toLowerCase()
+
+ const scores: Partial> = {}
+ for (const [prof, words] of Object.entries(KEYWORDS)) {
+ scores[prof as Profession] = words.filter((w) => text.includes(w)).length
+ }
+
+ const best = (Object.entries(scores) as [Profession, number][]).sort(
+ (a, b) => b[1] - a[1],
+ )[0]
+ return best && best[1] > 0 ? best[0] : "default"
+}
+
+let inflightPromise: Promise | null = null
+
+export function usePersonalization(): {
+ copy: PersonalizedCopy
+ profession: Profession
+ setProfession: (p: Profession) => void
+} {
+ const [copy, setCopy] = useState(COPY.default)
+ const [profession, setProfessionState] = useState("default")
+
+ const setProfession = useCallback((p: Profession) => {
+ try {
+ localStorage.setItem(
+ CACHE_KEY,
+ JSON.stringify({ profession: p, ts: Date.now() }),
+ )
+ } catch {}
+ setCopy(COPY[p])
+ setProfessionState(p)
+ }, [])
+
+ useEffect(() => {
+ try {
+ const raw = localStorage.getItem(CACHE_KEY)
+ if (raw) {
+ const { profession: cached, ts } = JSON.parse(raw) as {
+ profession: Profession
+ ts: number
+ }
+ if (Date.now() - ts < CACHE_TTL_MS && COPY[cached]) {
+ setCopy(COPY[cached])
+ setProfessionState(cached)
+ return
+ }
+ }
+ } catch {}
+
+ if (inflightPromise) {
+ inflightPromise.then(() => {
+ try {
+ const raw = localStorage.getItem(CACHE_KEY)
+ if (raw) {
+ const { profession: cached } = JSON.parse(raw) as {
+ profession: Profession
+ }
+ if (COPY[cached]) {
+ setCopy(COPY[cached])
+ setProfessionState(cached)
+ }
+ }
+ } catch {}
+ })
+ return
+ }
+
+ inflightPromise = $fetch("@post/search", {
+ body: {
+ q: "career profession field industry background work role",
+ limit: 8,
+ },
+ })
+ .then((res) => {
+ const results = res.data?.results
+ if (!results?.length) return
+ const detected = classifyProfession(results)
+ try {
+ localStorage.setItem(
+ CACHE_KEY,
+ JSON.stringify({ profession: detected, ts: Date.now() }),
+ )
+ } catch {}
+ setCopy(COPY[detected])
+ setProfessionState(detected)
+ })
+ .catch(() => {})
+ .finally(() => {
+ inflightPromise = null
+ })
+ }, [])
+
+ return { copy, profession, setProfession }
+}
+
+export function clearPersonalizationCache() {
+ try {
+ localStorage.removeItem(CACHE_KEY)
+ } catch {}
+}
diff --git a/apps/web/hooks/use-processing-documents.ts b/apps/web/hooks/use-processing-documents.ts
new file mode 100644
index 000000000..72ea06fda
--- /dev/null
+++ b/apps/web/hooks/use-processing-documents.ts
@@ -0,0 +1,87 @@
+"use client"
+
+import { useEffect, useMemo, useRef } from "react"
+import { useQuery, useQueryClient } from "@tanstack/react-query"
+import { $fetch } from "@lib/api"
+import { useProject } from "@/stores"
+import { useAuth } from "@lib/auth-context"
+
+const MAX_POLLS = 60
+const POLL_INTERVAL_MS = 5_000
+
+export function useProcessingDocuments() {
+ const { user } = useAuth()
+ const { effectiveContainerTags } = useProject()
+ const queryClient = useQueryClient()
+ const prevIdsRef = useRef>(new Set())
+
+ const { data } = useQuery({
+ queryKey: ["processing-documents", effectiveContainerTags],
+ queryFn: async () => {
+ const response = await $fetch("@get/documents/processing", {
+ query: { containerTags: effectiveContainerTags },
+ disableValidation: true,
+ })
+ if (response.error) return { documents: [], totalCount: 0 }
+ return response.data ?? { documents: [], totalCount: 0 }
+ },
+ enabled: !!user,
+ refetchInterval: (query) => {
+ const count =
+ (query.state.data as { totalCount?: number } | undefined)?.totalCount ??
+ 0
+ const polls = query.state.dataUpdateCount
+ if (count === 0 || polls >= MAX_POLLS) return false
+ return POLL_INTERVAL_MS
+ },
+ staleTime: 0,
+ })
+
+ const docs =
+ (
+ data as
+ | { documents?: Array<{ id?: string | null; status?: string | null }> }
+ | undefined
+ )?.documents ?? []
+
+ const processingMap = useMemo(() => {
+ const map = new Map()
+ for (const doc of docs) {
+ if (doc.id && doc.status) {
+ map.set(doc.id, doc.status)
+ }
+ }
+ return map
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [docs])
+
+ // Detect docs that just finished (present in previous poll, absent now).
+ // Done here — not in the card — because card remounts reset per-card refs
+ // and lose the transition signal.
+ useEffect(() => {
+ const prev = prevIdsRef.current
+ const current = new Set(processingMap.keys())
+ prevIdsRef.current = current
+
+ const justFinished = [...prev].filter((id) => !current.has(id))
+ if (justFinished.length === 0) return
+
+ const refresh = () => {
+ queryClient.refetchQueries({ queryKey: ["documents-with-memories"] })
+ queryClient.refetchQueries({ queryKey: ["dashboard-recents"] })
+ }
+
+ // First pass: give the backend ~1s to finish writing memory entries
+ const t1 = setTimeout(refresh, 1000)
+ // Second pass: insurance in case the first fetch still beat the writes
+ const t2 = setTimeout(refresh, 4000)
+
+ return () => {
+ clearTimeout(t1)
+ clearTimeout(t2)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [processingMap.keys, queryClient.refetchQueries])
+
+ return processingMap
+}
diff --git a/apps/web/hooks/use-reset-organization.ts b/apps/web/hooks/use-reset-organization.ts
new file mode 100644
index 000000000..4c71580fb
--- /dev/null
+++ b/apps/web/hooks/use-reset-organization.ts
@@ -0,0 +1,43 @@
+"use client"
+
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { toast } from "sonner"
+import { $fetch } from "@lib/api"
+
+export function useResetOrganization() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (body: { confirmation: string }) => {
+ const res = await $fetch("@post/settings/reset", {
+ body,
+ retry: { attempts: 0 },
+ })
+ if (res.error) {
+ const e = res.error as Record
+ const msg =
+ typeof e.error === "string"
+ ? e.error
+ : typeof e.message === "string"
+ ? e.message
+ : "Reset failed"
+ throw new Error(msg)
+ }
+ if (!res.data?.success) throw new Error("Reset failed")
+ return res.data
+ },
+ onSuccess: async () => {
+ queryClient.invalidateQueries()
+ // Clear the daily brief Cache API entry so stale highlights don't survive the reset
+ try {
+ await caches.delete("space-highlights-v1")
+ } catch {
+ // Cache API not available in all environments
+ }
+ toast.success("Organization data has been reset.")
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to reset organization.")
+ },
+ })
+}
diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts
index ff3d25210..eef87c146 100644
--- a/apps/web/lib/analytics.ts
+++ b/apps/web/lib/analytics.ts
@@ -29,8 +29,9 @@ export const analytics = {
chatHistoryViewed: () => safeCapture("chat_history_viewed"),
chatDeleted: () => safeCapture("chat_deleted"),
- viewModeChanged: (mode: "graph" | "list" | "integrations") =>
- safeCapture("view_mode_changed", { mode }),
+ viewModeChanged: (
+ mode: "dashboard" | "graph" | "list" | "integrations" | "chat",
+ ) => safeCapture("view_mode_changed", { mode }),
documentCardClicked: () => safeCapture("document_card_clicked"),
@@ -117,8 +118,9 @@ export const analytics = {
}) => safeCapture("highlight_clicked", props),
// chat analytics
- chatMessageSent: (props: { source: "typed" | "suggested" | "highlight" }) =>
- safeCapture("chat_message_sent", props),
+ chatMessageSent: (props: {
+ source: "typed" | "suggested" | "highlight" | "home"
+ }) => safeCapture("chat_message_sent", props),
chatSuggestedQuestionClicked: () =>
safeCapture("chat_suggested_question_clicked"),
diff --git a/apps/web/lib/chat-highlight-documents.ts b/apps/web/lib/chat-highlight-documents.ts
new file mode 100644
index 000000000..027e37c7a
--- /dev/null
+++ b/apps/web/lib/chat-highlight-documents.ts
@@ -0,0 +1,214 @@
+import type { UIMessage } from "@ai-sdk/react"
+import { memoryResultsFromSearchToolOutput } from "@/lib/chat-search-memory-results"
+
+const UUID_IN_STRING =
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
+
+const UUID_STRICT =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+
+// Matches [doc:] annotations emitted by sgrep when includeDocIds is enabled.
+// Supermemory uses NanoIDs (alphanumeric + _ -), not UUIDs.
+const DOC_ANNOTATION = /\[doc:([A-Za-z0-9_-]{10,40})\]/g
+
+function collectIdsFromDynamicTool(part: Record): string[] {
+ const toolName = part.toolName
+ if (!part.output) return []
+
+ if (toolName === "searchMemories") {
+ return memoryResultsFromSearchToolOutput(part.output)
+ .map((r) => r.documentId)
+ .filter((id): id is string => Boolean(id))
+ }
+
+ if (toolName === "bash") {
+ return documentIdsFromBashText(
+ extractBashOutputString(part.output as Record),
+ )
+ }
+
+ const fromWalk: string[] = []
+ collectDocumentIdsFromUnknown(part.output, fromWalk)
+ return fromWalk
+}
+
+function extractBashOutputString(output: Record): string {
+ const stdout = output.stdout
+ return typeof stdout === "string" ? stdout : ""
+}
+
+/** Heuristic: pull document IDs from bash stdout. Handles [doc:] annotations, UUID patterns, and JSON documentId fields. */
+export function documentIdsFromBashText(text: string): string[] {
+ const found = new Set()
+ // [doc:] annotations from sgrep --include-doc-ids (highest confidence)
+ for (const m of text.matchAll(DOC_ANNOTATION)) {
+ found.add(m[1])
+ }
+ // Standard UUID format
+ for (const m of text.matchAll(UUID_IN_STRING)) {
+ found.add(m[0].toLowerCase())
+ }
+ // JSON "documentId": "..." fields
+ const quoted = /"documentId"\s*:\s*"([^"]+)"/g
+ let q = quoted.exec(text)
+ while (q !== null) {
+ found.add(q[1])
+ q = quoted.exec(text)
+ }
+ return [...found]
+}
+
+function collectDocumentIdsFromUnknown(value: unknown, out: string[]): void {
+ const seen = new Set()
+ const walk = (v: unknown, depth: number) => {
+ if (depth > 18) return
+ if (v === null || v === undefined) return
+ if (typeof v === "string") {
+ if (v.length > 0 && v.length < 400000) {
+ for (const id of documentIdsFromBashText(v)) {
+ if (!seen.has(id)) {
+ seen.add(id)
+ out.push(id)
+ }
+ }
+ }
+ return
+ }
+ if (Array.isArray(v)) {
+ for (const x of v) walk(x, depth + 1)
+ return
+ }
+ if (typeof v !== "object") return
+ const o = v as Record
+
+ const docId = o.documentId
+ if (
+ typeof docId === "string" &&
+ UUID_STRICT.test(docId) &&
+ !seen.has(docId)
+ ) {
+ seen.add(docId)
+ out.push(docId)
+ }
+
+ if (Array.isArray(o.documents)) {
+ for (const d of o.documents) {
+ if (!d || typeof d !== "object") continue
+ const doc = d as Record
+ const id = doc.id
+ if (typeof id === "string" && UUID_STRICT.test(id) && !seen.has(id)) {
+ seen.add(id)
+ out.push(id)
+ }
+ }
+ }
+
+ for (const key of [
+ "results",
+ "memories",
+ "chunks",
+ "hits",
+ "items",
+ "data",
+ ]) {
+ if (key in o) walk(o[key], depth + 1)
+ }
+ }
+ walk(value, 0)
+}
+
+function toolOutputReady(p: Record): boolean {
+ const s = p.state
+ return (
+ s === "output-available" ||
+ s === "done" ||
+ (s === undefined && p.output !== undefined)
+ )
+}
+
+/** Document IDs referenced by retrieval tools / sources in this thread. */
+export function extractHighlightDocumentIdsFromMessages(
+ messages: UIMessage[],
+): string[] {
+ const ids = new Set()
+
+ for (const message of messages) {
+ if (message.role !== "assistant") continue
+ const parts = message.parts
+ if (!parts) continue
+
+ for (const part of parts) {
+ const p = part as Record
+
+ if (p.type === "source-document") {
+ const sid = (p as { sourceId?: unknown }).sourceId
+ if (typeof sid === "string" && UUID_STRICT.test(sid)) {
+ ids.add(sid)
+ }
+ continue
+ }
+
+ if (p.type === "tool-searchMemories" && toolOutputReady(p)) {
+ for (const id of memoryResultsFromSearchToolOutput(p.output)
+ .map((r) => r.documentId)
+ .filter(Boolean)) {
+ ids.add(id as string)
+ }
+ continue
+ }
+
+ if (p.type === "dynamic-tool" && toolOutputReady(p)) {
+ for (const id of collectIdsFromDynamicTool(p)) {
+ ids.add(id)
+ }
+ continue
+ }
+
+ if (
+ typeof p.type === "string" &&
+ p.type.startsWith("tool-") &&
+ toolOutputReady(p)
+ ) {
+ const name = p.type.slice("tool-".length)
+ if (name === "searchMemories") {
+ for (const id of memoryResultsFromSearchToolOutput(p.output)
+ .map((r) => r.documentId)
+ .filter(Boolean)) {
+ ids.add(id as string)
+ }
+ } else if (name === "bash") {
+ const out = p.output as Record | undefined
+ const stdout = out && typeof out.stdout === "string" ? out.stdout : ""
+ for (const id of documentIdsFromBashText(stdout)) {
+ ids.add(id)
+ }
+ } else if (p.output) {
+ const buf: string[] = []
+ collectDocumentIdsFromUnknown(p.output, buf)
+ for (const id of buf) ids.add(id)
+ }
+ }
+ }
+ }
+
+ if (ids.size === 0) {
+ const lastAssistant = [...messages]
+ .reverse()
+ .find((m) => m.role === "assistant")
+ const parts = lastAssistant?.parts
+ if (parts) {
+ const texts = parts
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
+ .map((p) => p.text)
+ .join("\n")
+ let n = 0
+ for (const m of texts.matchAll(UUID_IN_STRING)) {
+ if (n >= 16) break
+ ids.add(m[0].toLowerCase())
+ n++
+ }
+ }
+ }
+
+ return [...ids]
+}
diff --git a/apps/web/lib/search-params.ts b/apps/web/lib/search-params.ts
index 8eed98f06..9868a8979 100644
--- a/apps/web/lib/search-params.ts
+++ b/apps/web/lib/search-params.ts
@@ -23,12 +23,25 @@ export const shareParam = parseAsBoolean.withDefault(false)
export const feedbackParam = parseAsBoolean.withDefault(false)
// View & filter states
-const viewLiterals = ["graph", "list", "integrations"] as const
-const integrationLiterals = ["import", "chrome", "connections"] as const
+const viewLiterals = [
+ "dashboard",
+ "graph",
+ "list",
+ "integrations",
+ "chat",
+] as const
+const integrationLiterals = [
+ "import",
+ "chrome",
+ "connections",
+ "notion",
+ "google-drive",
+] as const
export type IntegrationParamValue = (typeof integrationLiterals)[number]
export const integrationParam = parseAsStringLiteral(integrationLiterals)
export type ViewParamValue = (typeof viewLiterals)[number]
-export const viewParam = parseAsStringLiteral(viewLiterals).withDefault("list")
+export const viewParam =
+ parseAsStringLiteral(viewLiterals).withDefault("dashboard")
export const pluginsPanelParam = parseAsBoolean
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(
diff --git a/apps/web/public/onboarding-complete.png b/apps/web/public/onboarding-complete.png
deleted file mode 100644
index 329cccdaa..000000000
Binary files a/apps/web/public/onboarding-complete.png and /dev/null differ
diff --git a/apps/web/public/onboarding.png b/apps/web/public/onboarding.png
deleted file mode 100644
index f8ba97b18..000000000
Binary files a/apps/web/public/onboarding.png and /dev/null differ
diff --git a/apps/web/stores/index.ts b/apps/web/stores/index.ts
index be9a9accd..5379b06ee 100644
--- a/apps/web/stores/index.ts
+++ b/apps/web/stores/index.ts
@@ -2,30 +2,18 @@
import { useQueryState } from "nuqs"
import { projectParam } from "@/lib/search-params"
-import { useCallback, useMemo } from "react"
+import { useCallback } from "react"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
-import { useContainerTags } from "@/hooks/use-container-tags"
export function useProject() {
const [selectedProjects, _setSelectedProjects] = useQueryState(
"project",
projectParam,
)
- const { novaContainerTags } = useContainerTags()
- const isNovaSpaces = selectedProjects.length === 0
+ const selectedProject = selectedProjects[0] ?? DEFAULT_PROJECT_ID
- const selectedProject = isNovaSpaces
- ? DEFAULT_PROJECT_ID
- : (selectedProjects[0] ?? DEFAULT_PROJECT_ID)
-
- // Get effective container tags for API calls
- // When "Nova Spaces" is selected, use all nova container tags
- // Otherwise, use the selected projects
- const effectiveContainerTags = useMemo(
- () => (isNovaSpaces ? novaContainerTags : selectedProjects),
- [isNovaSpaces, novaContainerTags, selectedProjects],
- )
+ const effectiveContainerTags = selectedProjects
const setSelectedProjects = useCallback(
(projects: string[]) => {
@@ -46,9 +34,7 @@ export function useProject() {
selectedProject,
setSelectedProjects,
setSelectedProject,
- isNovaSpaces,
effectiveContainerTags,
- novaContainerTags,
}
}
diff --git a/packages/lib/api.ts b/packages/lib/api.ts
index 777feb089..169c41e5b 100644
--- a/packages/lib/api.ts
+++ b/packages/lib/api.ts
@@ -19,6 +19,7 @@ import {
MemoryResponseSchema,
MigrateMCPRequestSchema,
MigrateMCPResponseSchema,
+ ProcessingDocumentsResponseSchema,
ProjectSchema,
SearchRequestSchema,
SearchResponseSchema,
@@ -132,6 +133,19 @@ export const apiSchema = createSchema({
input: SettingsRequestSchema,
output: SettingsResponseSchema,
},
+ "@post/settings/reset": {
+ input: z.object({ confirmation: z.string() }),
+ output: z.object({
+ success: z.boolean(),
+ deletedConnections: z.number(),
+ deletedDocumentBatches: z.number(),
+ deletedDocumentsApprox: z.number(),
+ deletedMemoryRows: z.number(),
+ deletedExtraSpaces: z.number(),
+ clearedDefaultSpaceContext: z.boolean(),
+ settingsReset: z.boolean(),
+ }),
+ },
// Memory operations
"@post/documents": {
input: MemoryAddSchema,
@@ -165,6 +179,15 @@ export const apiSchema = createSchema({
output: MigrateMCPResponseSchema,
},
+ "@get/documents/processing": {
+ output: ProcessingDocumentsResponseSchema,
+ query: z
+ .object({
+ containerTags: z.array(z.string()).optional(),
+ })
+ .optional(),
+ },
+
"@get/documents/:id": {
output: z.any(),
},
diff --git a/packages/memory-graph/src/__tests__/mock-data.test.ts b/packages/memory-graph/src/__tests__/mock-data.test.ts
index 4774497d6..03f7813b2 100644
--- a/packages/memory-graph/src/__tests__/mock-data.test.ts
+++ b/packages/memory-graph/src/__tests__/mock-data.test.ts
@@ -7,8 +7,8 @@ describe("generateMockGraphData", () => {
const data2 = generateMockGraphData({ documentCount: 10, seed: 42 })
expect(data1.documents.length).toBe(data2.documents.length)
- expect(data1.documents[0]!.id).toBe(data2.documents[0]!.id)
- expect(data1.documents[0]!.title).toBe(data2.documents[0]!.title)
+ expect(data1.documents[0]?.id).toBe(data2.documents[0]?.id)
+ expect(data1.documents[0]?.title).toBe(data2.documents[0]?.title)
})
it("produces different output with different seeds", () => {
@@ -43,8 +43,9 @@ describe("generateMockGraphData", () => {
const data = generateMockGraphData({ documentCount: 5, seed: 1 })
const doc = data.documents.find((d) => d.memories.length > 0)
expect(doc).toBeDefined()
+ if (!doc) return
- for (const mem of doc!.memories) {
+ for (const mem of doc.memories) {
expect(mem.id).toBeDefined()
expect(mem.memory).toBeDefined()
expect(typeof mem.isStatic).toBe("boolean")
diff --git a/packages/memory-graph/src/canvas/renderer.ts b/packages/memory-graph/src/canvas/renderer.ts
index 3c0d66b1f..a3e1be300 100644
--- a/packages/memory-graph/src/canvas/renderer.ts
+++ b/packages/memory-graph/src/canvas/renderer.ts
@@ -18,6 +18,15 @@ export interface RenderState {
// Module-level reusable batch map – cleared each frame instead of reallocating
const edgeBatches = new Map()
+function nodeMatchesDocumentHighlights(
+ node: GraphNode,
+ highlightIds: Set,
+): boolean {
+ if (highlightIds.size === 0) return false
+ if (node.type === "document") return highlightIds.has(node.id)
+ return highlightIds.has((node.data as MemoryNodeData).documentId)
+}
+
/** Group items by their `color` property into batches for efficient canvas drawing */
function groupByColor(
items: T[],
@@ -296,7 +305,13 @@ function drawNodes(
const isSelected = node.id === state.selectedNodeId
const isHovered = node.id === state.hoveredNodeId
- const isHighlighted = state.highlightIds.has(node.id)
+ const isHighlighted = nodeMatchesDocumentHighlights(
+ node,
+ state.highlightIds,
+ )
+ const highlightFocus = state.highlightIds.size > 0
+ const fadeNonHighlights =
+ highlightFocus && !isSelected && !isHovered && !isHighlighted
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
if (node.type === "document") {
@@ -318,6 +333,9 @@ function drawNodes(
if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) {
alpha = 1 - state.dimProgress * 0.7
}
+ if (fadeNonHighlights) {
+ alpha *= 0.35
+ }
ctx.globalAlpha = alpha
if (node.type === "document") {
@@ -363,12 +381,13 @@ function drawNodes(
state.selectedNodeId && state.dimProgress > 0
? 1 - state.dimProgress * 0.7
: 1
+ const hlBatchMult = state.highlightIds.size > 0 ? 0.4 : 1
if (docDots.length > 0) {
ctx.fillStyle = colors.docFill
ctx.strokeStyle = colors.docStroke
ctx.lineWidth = 1
- ctx.globalAlpha = dimAlpha
+ ctx.globalAlpha = dimAlpha * hlBatchMult
for (const d of docDots) {
const h = d.s * 0.5
ctx.fillRect(d.x - h, d.y - h, d.s, d.s)
@@ -383,7 +402,7 @@ function drawNodes(
if (normalDots.length > 0) {
// Subtle glow behind memory dots for luminous effect
- ctx.globalAlpha = dimAlpha * 0.25
+ ctx.globalAlpha = dimAlpha * hlBatchMult * 0.25
for (const [color, batch] of groupByColor(normalDots)) {
ctx.fillStyle = color
ctx.beginPath()
@@ -395,7 +414,7 @@ function drawNodes(
}
// Filled dot
- ctx.globalAlpha = dimAlpha
+ ctx.globalAlpha = dimAlpha * hlBatchMult
ctx.fillStyle = colors.memFill
ctx.beginPath()
for (const d of normalDots) {
@@ -419,7 +438,7 @@ function drawNodes(
// Draw dimmed (superseded) memory dots at reduced opacity
if (dimmedDots.length > 0) {
- ctx.globalAlpha = dimAlpha * 0.5
+ ctx.globalAlpha = dimAlpha * hlBatchMult * 0.5
ctx.fillStyle = colors.memFill
ctx.beginPath()
for (const d of dimmedDots) {
diff --git a/packages/memory-graph/src/components/graph-canvas.tsx b/packages/memory-graph/src/components/graph-canvas.tsx
index f195e0ba8..c5aea9101 100644
--- a/packages/memory-graph/src/components/graph-canvas.tsx
+++ b/packages/memory-graph/src/components/graph-canvas.tsx
@@ -104,8 +104,24 @@ export const GraphCanvas = memo(function GraphCanvas({
}, [nodes])
useEffect(() => {
- s.current.highlightIds = new Set(highlightDocumentIds ?? [])
+ const ids = new Set(highlightDocumentIds ?? [])
+ s.current.highlightIds = ids
renderNeeded.current = true
+
+ if (ids.size === 0) return
+ const vp = viewportRef.current
+ if (!vp) return
+ const highlighted = s.current.nodes.filter((n) => {
+ if (n.type === "document") return ids.has(n.id)
+ const d = n.data as { documentId?: string }
+ return typeof d.documentId === "string" && ids.has(d.documentId)
+ })
+ if (highlighted.length === 0) return
+ vp.fitToNodes(
+ highlighted.map((n) => ({ x: n.x, y: n.y, size: n.size ?? 24 })),
+ s.current.width,
+ s.current.height,
+ )
}, [highlightDocumentIds])
useEffect(() => {