feat: empty state action for new spaces (#780)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mahesh Sanikommu 2026-03-15 23:32:14 +05:30 committed by GitHub
parent ba56b3699b
commit 962fb85cd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 369 additions and 123 deletions

View file

@ -221,7 +221,11 @@ export class SupermemoryClient {
}
// Search memories using SDK
async search(query: string, limit = 10, threshold?: number): Promise<SearchResult> {
async search(
query: string,
limit = 10,
threshold?: number,
): Promise<SearchResult> {
try {
const result = await this.client.search.memories({
q: query,

View file

@ -19,6 +19,8 @@ import { AnimatePresence } from "motion/react"
import { useIsMobile } from "@hooks/use-mobile"
import { useAuth } from "@lib/auth-context"
import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import {
useQuickNoteDraftReset,
useQuickNoteDraft,
@ -38,6 +40,7 @@ import {
docParam,
fullscreenParam,
chatParam,
integrationParam,
} from "@/lib/search-params"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
@ -63,7 +66,21 @@ function ViewErrorFallback() {
export default function NewPage() {
const isMobile = useIsMobile()
const { user, session } = useAuth()
const { selectedProject, isNovaSpaces, novaContainerTags } = useProject()
const { selectedProject, isNovaSpaces, novaContainerTags, selectedProjects } =
useProject()
const selectedProjectTag = selectedProjects[0]
const isNovaContext =
isNovaSpaces ||
(selectedProjectTag !== undefined &&
novaContainerTags.includes(selectedProjectTag))
const { allProjects } = useContainerTags()
const emptyStateSpaceName =
!isNovaSpaces && selectedProjectTag
? selectedProjectTag === DEFAULT_PROJECT_ID
? "My Space"
: (allProjects.find((p) => p.containerTag === selectedProjectTag)
?.name ?? selectedProjectTag)
: undefined
const { viewMode, setViewMode } = useViewMode()
const queryClient = useQueryClient()
@ -93,6 +110,7 @@ export default function NewPage() {
fullscreenParam,
)
const [isChatOpen, setIsChatOpen] = useQueryState("chat", chatParam)
const [, setIntegration] = useQueryState("integration", integrationParam)
// Ephemeral local state (not worth URL-encoding)
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
@ -348,6 +366,26 @@ export default function NewPage() {
[setSearchPrefill, setIsSearchOpen],
)
const handleOpenIntegrations = useCallback(
(integration?: "import" | "chrome" | "connections") => {
setViewMode("integrations")
if (integration) {
setIntegration(integration)
} else {
setIntegration(null)
}
},
[setViewMode, setIntegration],
)
const handleAddMemory = useCallback(
(tab: "note" | "link") => {
analytics.addDocumentModalOpened()
setAddDoc(tab)
},
[setAddDoc],
)
const chatOpen = isChatOpen !== null ? isChatOpen : !isMobile
const isGraphMode = viewMode === "graph" && !isMobile
@ -421,6 +459,16 @@ export default function NewPage() {
onShowRelated: handleHighlightsShowRelated,
isLoading: isLoadingHighlights,
}}
emptyStateProps={
isNovaContext
? {
onAddMemory: handleAddMemory,
onOpenIntegrations: handleOpenIntegrations,
isAllSpaces: isNovaSpaces,
spaceName: emptyStateSpaceName,
}
: undefined
}
/>
</div>
)}

View file

@ -1,6 +1,7 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { useQueryState } from "nuqs"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { Button } from "@ui/components/button"
@ -18,6 +19,10 @@ import {
} from "@/components/integration-icons"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { ArrowLeft, Sun } from "lucide-react"
import {
integrationParam,
type IntegrationParamValue,
} from "@/lib/search-params"
import Image from "next/image"
type CardId =
@ -140,10 +145,29 @@ function DetailWrapper({
)
}
const INTEGRATION_TO_CARD: Record<IntegrationParamValue, CardId> = {
import: "import",
chrome: "chrome",
connections: "connections",
}
export function IntegrationsView() {
const [integration, setIntegration] = useQueryState(
"integration",
integrationParam,
)
const [selectedCard, setSelectedCard] = useState<CardId | null>(null)
const handleBack = () => setSelectedCard(null)
useEffect(() => {
if (integration && INTEGRATION_TO_CARD[integration]) {
setSelectedCard(INTEGRATION_TO_CARD[integration])
}
}, [integration])
const handleBack = () => {
setSelectedCard(null)
setIntegration(null)
}
switch (selectedCard) {
case "mcp":

View file

@ -31,6 +31,7 @@ import { HighlightsCard, type HighlightItem } from "./highlights-card"
import { GraphCard } from "./memory-graph"
import { Button } from "@ui/components/button"
import { categoriesParam } from "@/lib/search-params"
import { NovaEmptyState } from "@/components/nova/nova-empty-state"
import {
AlertDialog,
AlertDialogAction,
@ -92,6 +93,15 @@ interface HighlightsProps {
isLoading: boolean
}
interface NovaEmptyStateProps {
onAddMemory: (tab: "note" | "link") => void
onOpenIntegrations: (
integration?: "import" | "chrome" | "connections",
) => void
isAllSpaces: boolean
spaceName?: string
}
interface MemoriesGridProps {
isChatOpen: boolean
onOpenDocument: (document: DocumentWithMemories) => void
@ -105,6 +115,7 @@ interface MemoriesGridProps {
isBulkDeleting?: boolean
quickNoteProps?: QuickNoteProps
highlightsProps?: HighlightsProps
emptyStateProps?: NovaEmptyStateProps
}
export function MemoriesGrid({
@ -120,6 +131,7 @@ export function MemoriesGrid({
isBulkDeleting = false,
quickNoteProps,
highlightsProps,
emptyStateProps,
}: MemoriesGridProps) {
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const { user } = useAuth()
@ -340,99 +352,107 @@ export function MemoriesGrid({
)
}
const isEmpty = documents.length === 0 && !isPending
const showNovaEmptyState = isEmpty && emptyStateProps
return (
<div className="relative">
<div
id="filter-pills"
className="flex items-center justify-between gap-4 mb-3"
>
<div className="flex flex-wrap items-center gap-1.5">
<Button
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
selectedCategories.length === 0 &&
"bg-[#00173C] border-[#2261CA33]",
)}
onClick={handleSelectAll}
>
All
{facetsData?.total !== undefined && (
<span className="ml-1 text-[#737373]">({facetsData.total})</span>
)}
</Button>
{facetsData?.facets.map((facet: DocumentFacet) => (
{!isEmpty && (
<div
id="filter-pills"
className="flex items-center justify-between gap-4 mb-3"
>
<div className="flex flex-wrap items-center gap-1.5">
<Button
key={facet.category}
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
selectedCategories.includes(facet.category) &&
selectedCategories.length === 0 &&
"bg-[#00173C] border-[#2261CA33]",
)}
onClick={() => handleCategoryToggle(facet.category)}
onClick={handleSelectAll}
>
{facet.label}
<span className="ml-1 text-[#737373]">({facet.count})</span>
All
{facetsData?.total !== undefined && (
<span className="ml-1 text-[#737373]">
({facetsData.total})
</span>
)}
</Button>
))}
</div>
<div className="flex items-center gap-2 shrink-0">
{isSelectionMode && (
<>
{facetsData?.facets.map((facet: DocumentFacet) => (
<Button
key={facet.category}
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
selectedCategories.includes(facet.category) &&
"bg-[#00173C] border-[#2261CA33]",
)}
onClick={() => handleCategoryToggle(facet.category)}
>
{facet.label}
<span className="ml-1 text-[#737373]">({facet.count})</span>
</Button>
))}
</div>
<div className="flex items-center gap-2 shrink-0">
{isSelectionMode && (
<>
<button
type="button"
aria-label="Exit selection mode"
className="w-8 h-8 flex items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A] hover:bg-[#00173C] transition-colors cursor-pointer"
onClick={onClearSelection}
>
<XIcon className="w-4 h-4 text-[#737373]" />
</button>
{selectedDocumentIds.size > 0 ? (
<>
<button
type="button"
className={cn(
dmSansClassName(),
"text-xs text-[#737373] hover:text-white transition-colors cursor-pointer",
)}
onClick={handleSelectAllVisible}
>
Select all
</button>
<button
type="button"
className={cn(
dmSansClassName(),
"flex items-center gap-1 text-xs text-red-400 hover:text-red-300 transition-colors cursor-pointer disabled:opacity-50",
)}
onClick={handleBulkDeleteClick}
disabled={isBulkDeleting}
>
<Trash2Icon className="w-3 h-3" />
Delete ({selectedDocumentIds.size})
</button>
</>
) : (
<p
className={cn(dmSansClassName(), "text-xs text-[#737373]")}
>
Select one or more documents
</p>
)}
</>
)}
{!isSelectionMode && onEnterSelectionMode && (
<button
type="button"
aria-label="Exit selection mode"
aria-label="Enter selection mode"
className="w-8 h-8 flex items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A] hover:bg-[#00173C] transition-colors cursor-pointer"
onClick={onClearSelection}
onClick={onEnterSelectionMode}
>
<XIcon className="w-4 h-4 text-[#737373]" />
<div className="w-3 h-3 rounded-[2.25px] border border-[#737373]" />
</button>
{selectedDocumentIds.size > 0 ? (
<>
<button
type="button"
className={cn(
dmSansClassName(),
"text-xs text-[#737373] hover:text-white transition-colors cursor-pointer",
)}
onClick={handleSelectAllVisible}
>
Select all
</button>
<button
type="button"
className={cn(
dmSansClassName(),
"flex items-center gap-1 text-xs text-red-400 hover:text-red-300 transition-colors cursor-pointer disabled:opacity-50",
)}
onClick={handleBulkDeleteClick}
disabled={isBulkDeleting}
>
<Trash2Icon className="w-3 h-3" />
Delete ({selectedDocumentIds.size})
</button>
</>
) : (
<p className={cn(dmSansClassName(), "text-xs text-[#737373]")}>
Select one or more documents
</p>
)}
</>
)}
{!isSelectionMode && onEnterSelectionMode && (
<button
type="button"
aria-label="Enter selection mode"
className="w-8 h-8 flex items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A] hover:bg-[#00173C] transition-colors cursor-pointer"
onClick={onEnterSelectionMode}
>
<div className="w-3 h-3 rounded-[2.25px] border border-[#737373]" />
</button>
)}
)}
</div>
</div>
</div>
)}
<AlertDialog
open={showBulkDeleteConfirm}
@ -486,7 +506,14 @@ export function MemoriesGrid({
<div className="h-full flex items-center justify-center p-4">
<SuperLoader />
</div>
) : documents.length === 0 && !isPending ? (
) : showNovaEmptyState ? (
<NovaEmptyState
onAddMemory={emptyStateProps.onAddMemory}
onOpenIntegrations={emptyStateProps.onOpenIntegrations}
isAllSpaces={emptyStateProps.isAllSpaces}
spaceName={emptyStateProps.spaceName}
/>
) : isEmpty ? (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
No memories found

View file

@ -0,0 +1,146 @@
"use client"
import { CHROME_EXTENSION_URL } from "@repo/lib/constants"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { Button } from "@ui/components/button"
import NovaOrb from "./nova-orb"
import { ChromeIcon } from "@/components/integration-icons"
import { ArrowRight, Link2, FileText, Zap } from "lucide-react"
interface NovaEmptyStateProps {
onAddMemory: (tab: "note" | "link") => void
onOpenIntegrations: (
integration?: "import" | "chrome" | "connections",
) => void
isAllSpaces: boolean
spaceName?: string
}
const cardClass = cn(
"bg-[#14161A] rounded-xl p-4 border border-[rgba(82,89,102,0.2)]",
"hover:border-[#3374FF]/50 hover:bg-[#1B1F24]",
"transition-colors cursor-pointer text-left flex flex-col gap-2",
)
export function NovaEmptyState({
onAddMemory,
onOpenIntegrations,
isAllSpaces,
spaceName,
}: NovaEmptyStateProps) {
const handleInstallChrome = () => {
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer")
}
const title = isAllSpaces
? "Help Nova get to know you"
: "This space is empty"
const subtitle = isAllSpaces
? "Add your first memory to get started."
: spaceName
? `Add memories to ${spaceName} to get started.`
: "Add memories to this space to get started."
return (
<div
id="nova-empty-state"
className="min-h-[calc(100dvh-12rem)] flex items-center justify-center p-6 md:p-8 opacity-50 hover:opacity-100 transition-opacity duration-300"
>
<div className="max-w-xl w-full flex flex-col items-center text-center">
<NovaOrb size={80} className="blur-[2px]! mb-4" />
<h2
className={cn(
"text-white text-xl md:text-2xl font-medium mb-2",
dmSansClassName(),
)}
>
{title}
</h2>
<p className={cn("text-[#8B8B8B] text-sm mb-6", dmSansClassName())}>
{subtitle}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 w-full mb-4">
<button
type="button"
onClick={() => onAddMemory("link")}
className={cardClass}
>
<div className="flex items-center gap-2">
<Link2 className="size-5 text-[#737373]" />
<span
className={cn(
"font-medium text-white text-sm",
dmSansClassName(),
)}
>
Save a link
</span>
</div>
<span className="text-[#4BA0FA] text-xs font-medium flex items-center gap-1">
Add now <ArrowRight className="size-3" />
</span>
</button>
<button
type="button"
onClick={() => onAddMemory("note")}
className={cardClass}
>
<div className="flex items-center gap-2">
<FileText className="size-5 text-[#737373]" />
<span
className={cn(
"font-medium text-white text-sm",
dmSansClassName(),
)}
>
Write a note
</span>
</div>
<span className="text-[#4BA0FA] text-xs font-medium flex items-center gap-1">
Add now <ArrowRight className="size-3" />
</span>
</button>
<button
type="button"
onClick={handleInstallChrome}
className={cardClass}
>
<div className="flex items-center gap-2">
<ChromeIcon className="size-5" />
<span
className={cn(
"font-medium text-white text-sm",
dmSansClassName(),
)}
>
Chrome Extension
</span>
</div>
<span className="text-[#4BA0FA] text-xs font-medium flex items-center gap-1">
Install <ArrowRight className="size-3" />
</span>
</button>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenIntegrations()}
className={cn(
"text-[#737373] hover:text-white hover:bg-transparent",
"flex items-center gap-1.5",
dmSansClassName(),
)}
>
<Zap className="size-4" />
See more integrations
<ArrowRight className="size-3" />
</Button>
</div>
</div>
)
}

View file

@ -109,7 +109,8 @@
}
/* Override prose paragraph margins for text editor */
.text-editor-prose.prose :where(p):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
.text-editor-prose.prose
:where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 0;
margin-bottom: 0;
}

View file

@ -24,6 +24,9 @@ export const feedbackParam = parseAsBoolean.withDefault(false)
// View & filter states
const viewLiterals = ["graph", "list", "integrations"] as const
const integrationLiterals = ["import", "chrome", "connections"] 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 categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(

View file

@ -1,5 +1,10 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"actions": {
"source": {

View file

@ -135,14 +135,8 @@ export const getProfileTool = (
inputSchema: z.object({
containerTag: strict
? z.string().describe(PARAMETER_DESCRIPTIONS.containerTag)
: z
.string()
.optional()
.describe(PARAMETER_DESCRIPTIONS.containerTag),
query: z
.string()
.optional()
.describe(PARAMETER_DESCRIPTIONS.query),
: z.string().optional().describe(PARAMETER_DESCRIPTIONS.containerTag),
query: z.string().optional().describe(PARAMETER_DESCRIPTIONS.query),
}),
execute: async ({ containerTag, query }) => {
try {
@ -197,14 +191,8 @@ export const documentListTool = (
.optional()
.default(DEFAULT_VALUES.limit)
.describe(PARAMETER_DESCRIPTIONS.limit),
offset: z
.number()
.optional()
.describe(PARAMETER_DESCRIPTIONS.offset),
status: z
.string()
.optional()
.describe(PARAMETER_DESCRIPTIONS.status),
offset: z.number().optional().describe(PARAMETER_DESCRIPTIONS.offset),
status: z.string().optional().describe(PARAMETER_DESCRIPTIONS.status),
}),
execute: async ({ containerTag, limit, offset, status }) => {
try {
@ -329,10 +317,7 @@ export const memoryForgetTool = (
.string()
.optional()
.describe(PARAMETER_DESCRIPTIONS.containerTag),
memoryId: z
.string()
.optional()
.describe(PARAMETER_DESCRIPTIONS.memoryId),
memoryId: z.string().optional().describe(PARAMETER_DESCRIPTIONS.memoryId),
memoryContent: z
.string()
.optional()

View file

@ -37,7 +37,9 @@ export interface ProfileResult {
export interface DocumentListResult {
success: boolean
documents?: Awaited<ReturnType<Supermemory["documents"]["list"]>>["documents"]
pagination?: Awaited<ReturnType<Supermemory["documents"]["list"]>>["pagination"]
pagination?: Awaited<
ReturnType<Supermemory["documents"]["list"]>
>["pagination"]
error?: string
}

View file

@ -31,13 +31,15 @@ export const PARAMETER_DESCRIPTIONS = {
containerTag: "Tag to filter/scope the operation (e.g., user ID, project ID)",
query: "Optional search query to include relevant search results",
offset: "Number of items to skip for pagination (default: 0)",
status: "Filter documents by processing status (e.g., 'completed', 'processing', 'failed')",
status:
"Filter documents by processing status (e.g., 'completed', 'processing', 'failed')",
documentId: "The unique identifier of the document to operate on",
content: "The content to add - can be text, URL, or other supported formats",
title: "Optional title for the document",
description: "Optional description for the document",
memoryId: "The unique identifier of the memory entry",
memoryContent: "Exact content match of the memory entry to operate on (alternative to ID)",
memoryContent:
"Exact content match of the memory entry to operate on (alternative to ID)",
reason: "Optional reason for forgetting this memory",
} as const

View file

@ -1,26 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View file

@ -410,9 +410,8 @@ export const MemoryGraph = ({
{/* Show welcome screen when no memories exist */}
{!isLoading &&
(!data || !nodes.some((n) => n.type === "document")) && (
<>{children}</>
)}
(!data || !nodes.some((n) => n.type === "document")) &&
children}
{/* Graph container */}
<div