feat: bulk delete documents in nova app (#777)

![image.png](https://app.graphite.com/user-attachments/assets/aacbb007-cb5e-400c-a21e-f08a54320154.png)
This commit is contained in:
MaheshtheDev 2026-03-10 15:29:25 +00:00
parent a0514e7a44
commit 84d0fb802a
10 changed files with 501 additions and 133 deletions

View file

@ -137,13 +137,66 @@ export default function NewPage() {
const resetDraft = useQuickNoteDraftReset(selectedProject)
const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
const { noteMutation } = useDocumentMutations({
const { noteMutation, bulkDeleteMutation } = useDocumentMutations({
onClose: () => {
resetDraft()
setIsFullscreen(false)
},
})
const [selectedDocumentIds, setSelectedDocumentIds] = useState<Set<string>>(
new Set(),
)
const [isSelectionMode, setIsSelectionMode] = useState(false)
const handleToggleSelection = useCallback((documentId: string) => {
setSelectedDocumentIds((prev) => {
const next = new Set(prev)
if (next.has(documentId)) {
next.delete(documentId)
} else {
next.add(documentId)
}
return next
})
}, [])
const handleClearSelection = useCallback(() => {
setSelectedDocumentIds(new Set())
setIsSelectionMode(false)
}, [])
const handleEnterSelectionMode = useCallback(() => {
setIsSelectionMode(true)
}, [])
const handleSelectAllVisible = useCallback((visibleIds: string[]) => {
setSelectedDocumentIds((prev) => {
const next = new Set(prev)
for (const id of visibleIds) {
next.add(id)
}
return next
})
}, [])
const handleBulkDelete = useCallback(() => {
const ids = Array.from(selectedDocumentIds)
if (ids.length === 0) return
bulkDeleteMutation.mutate(
{ documentIds: ids },
{
onSuccess: () => {
setSelectedDocumentIds(new Set())
setIsSelectionMode(false)
if (selectedDocument && ids.includes(selectedDocument.id ?? "")) {
setDocId(null)
}
},
},
)
}, [selectedDocumentIds, bulkDeleteMutation, selectedDocument, setDocId])
type SpaceHighlightsResponse = {
highlights: HighlightItem[]
questions: string[]
@ -349,6 +402,14 @@ export default function NewPage() {
<MemoriesGrid
isChatOpen={chatOpen}
onOpenDocument={handleOpenDocument}
isSelectionMode={isSelectionMode}
selectedDocumentIds={selectedDocumentIds}
onEnterSelectionMode={handleEnterSelectionMode}
onToggleSelection={handleToggleSelection}
onClearSelection={handleClearSelection}
onSelectAllVisible={handleSelectAllVisible}
onBulkDelete={handleBulkDelete}
isBulkDeleting={bulkDeleteMutation.isPending}
quickNoteProps={{
onSave: handleQuickNoteSave,
onMaximize: handleMaximize,

View file

@ -185,7 +185,9 @@ export default function MigrateMCPPage() {
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-blue-500/50 focus:ring-blue-500/20 transition-all duration-200 pl-4 pr-4 py-3 rounded-xl"
disabled={migrateMutation.isPending}
id="mcpUrl"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMcpUrl(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpUrl(e.target.value)
}
placeholder="https://mcp.supermemory.ai/userId/sse"
type="url"
value={mcpUrl}
@ -205,7 +207,9 @@ export default function MigrateMCPPage() {
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-blue-500/50 focus:ring-blue-500/20 transition-all duration-200 pl-4 pr-4 py-3 rounded-xl"
disabled={migrateMutation.isPending}
id="projectId"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setProjectId(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProjectId(e.target.value)
}
placeholder="Project ID (default: 'default')"
type="text"
value={projectId}

View file

@ -675,104 +675,104 @@ export function ChatSidebar({
<HistoryIcon className="size-4 text-[#737373]" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg bg-[#0A0E14] border-[#17181AB2] text-white">
<DialogHeader className="pb-4 border-b border-[#17181AB2]">
<DialogTitle>Chat History</DialogTitle>
<DialogDescription className="text-[#737373]">
Project: {selectedProject}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-96">
{isLoadingThreads ? (
<div className="flex items-center justify-center py-8">
<SuperLoader label="Loading..." />
</div>
) : threads.length === 0 ? (
<div className="text-sm text-[#737373] text-center py-8">
No conversations yet
</div>
) : (
<div className="flex flex-col gap-1">
{threads.map((thread) => {
const isActive = thread.id === currentChatId
return (
<button
key={thread.id}
type="button"
onClick={() => loadThread(thread.id)}
className={cn(
"flex items-center justify-between rounded-md px-3 py-2 w-full text-left transition-colors",
isActive
? "bg-[#267BF1]/10"
: "hover:bg-[#17181A]",
)}
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{thread.title || "Untitled Chat"}
</div>
<div className="text-xs text-[#737373]">
{formatRelativeTime(thread.updatedAt)}
</div>
<DialogContent className="sm:max-w-lg bg-[#0A0E14] border-[#17181AB2] text-white">
<DialogHeader className="pb-4 border-b border-[#17181AB2]">
<DialogTitle>Chat History</DialogTitle>
<DialogDescription className="text-[#737373]">
Project: {selectedProject}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-96">
{isLoadingThreads ? (
<div className="flex items-center justify-center py-8">
<SuperLoader label="Loading..." />
</div>
) : threads.length === 0 ? (
<div className="text-sm text-[#737373] text-center py-8">
No conversations yet
</div>
) : (
<div className="flex flex-col gap-1">
{threads.map((thread) => {
const isActive = thread.id === currentChatId
return (
<button
key={thread.id}
type="button"
onClick={() => loadThread(thread.id)}
className={cn(
"flex items-center justify-between rounded-md px-3 py-2 w-full text-left transition-colors",
isActive
? "bg-[#267BF1]/10"
: "hover:bg-[#17181A]",
)}
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{thread.title || "Untitled Chat"}
</div>
{confirmingDeleteId === thread.id ? (
<div className="flex items-center gap-1 ml-2">
<Button
type="button"
size="icon"
onClick={(e) => {
e.stopPropagation()
deleteThread(thread.id)
}}
className="bg-red-500 text-white hover:bg-red-600 h-7 w-7"
>
<Check className="size-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
setConfirmingDeleteId(null)
}}
className="h-7 w-7"
>
<XIcon className="size-3 text-[#737373]" />
</Button>
</div>
) : (
<div className="text-xs text-[#737373]">
{formatRelativeTime(thread.updatedAt)}
</div>
</div>
{confirmingDeleteId === thread.id ? (
<div className="flex items-center gap-1 ml-2">
<Button
type="button"
size="icon"
onClick={(e) => {
e.stopPropagation()
deleteThread(thread.id)
}}
className="bg-red-500 text-white hover:bg-red-600 h-7 w-7"
>
<Check className="size-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
setConfirmingDeleteId(thread.id)
setConfirmingDeleteId(null)
}}
className="h-7 w-7 ml-2"
className="h-7 w-7"
>
<Trash2 className="size-3 text-[#737373]" />
<XIcon className="size-3 text-[#737373]" />
</Button>
)}
</button>
)
})}
</div>
)}
</ScrollArea>
<Button
variant="outline"
className="w-full border-dashed border-[#73737333] bg-transparent hover:bg-[#17181A]"
onClick={() => {
handleNewChat()
setIsHistoryOpen(false)
}}
>
<Plus className="size-4 mr-1" /> New Conversation
</Button>
</DialogContent>
</Dialog>
</div>
) : (
<Button
type="button"
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
setConfirmingDeleteId(thread.id)
}}
className="h-7 w-7 ml-2"
>
<Trash2 className="size-3 text-[#737373]" />
</Button>
)}
</button>
)
})}
</div>
)}
</ScrollArea>
<Button
variant="outline"
className="w-full border-dashed border-[#73737333] bg-transparent hover:bg-[#17181A]"
onClick={() => {
handleNewChat()
setIsHistoryOpen(false)
}}
>
<Plus className="size-4 mr-1" /> New Conversation
</Button>
</DialogContent>
</Dialog>
<Button
variant="headers"
className="rounded-full text-base gap-3 h-10! border-[#73737333] bg-[#0D121A] cursor-pointer"

View file

@ -31,6 +31,17 @@ 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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@ui/components/alert-dialog"
import { CheckIcon, Trash2Icon, XIcon } from "lucide-react"
// Document category type
type DocumentCategory =
@ -84,6 +95,14 @@ interface HighlightsProps {
interface MemoriesGridProps {
isChatOpen: boolean
onOpenDocument: (document: DocumentWithMemories) => void
isSelectionMode?: boolean
selectedDocumentIds?: Set<string>
onEnterSelectionMode?: () => void
onToggleSelection?: (documentId: string) => void
onClearSelection?: () => void
onSelectAllVisible?: (visibleIds: string[]) => void
onBulkDelete?: () => void
isBulkDeleting?: boolean
quickNoteProps?: QuickNoteProps
highlightsProps?: HighlightsProps
}
@ -91,11 +110,20 @@ interface MemoriesGridProps {
export function MemoriesGrid({
isChatOpen,
onOpenDocument,
isSelectionMode = false,
selectedDocumentIds = new Set(),
onEnterSelectionMode,
onToggleSelection,
onClearSelection,
onSelectAllVisible,
onBulkDelete,
isBulkDeleting = false,
quickNoteProps,
highlightsProps,
}: MemoriesGridProps) {
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const { user } = useAuth()
const { selectedProjects, effectiveContainerTags } = useProject()
const { effectiveContainerTags } = useProject()
const isMobile = useIsMobile()
const [selectedCategories, setSelectedCategories] = useQueryState(
"categories",
@ -241,11 +269,31 @@ export function MemoriesGrid({
const handleCardClick = useCallback(
(document: DocumentWithMemories) => {
onOpenDocument(document)
if (isSelectionMode && onToggleSelection && document.id) {
onToggleSelection(document.id)
} else {
onOpenDocument(document)
}
},
[onOpenDocument],
[isSelectionMode, onToggleSelection, onOpenDocument],
)
const handleSelectAllVisible = useCallback(() => {
if (onSelectAllVisible) {
onSelectAllVisible(documents.map((d) => d.id).filter(Boolean) as string[])
}
}, [documents, onSelectAllVisible])
const handleBulkDeleteClick = useCallback(() => {
if (selectedDocumentIds.size === 0) return
setShowBulkDeleteConfirm(true)
}, [selectedDocumentIds.size])
const handleBulkDeleteConfirm = useCallback(() => {
setShowBulkDeleteConfirm(false)
onBulkDelete?.()
}, [onBulkDelete])
const renderMasonryItem = useCallback(
({
index,
@ -257,13 +305,21 @@ export function MemoriesGrid({
width: number
}) => {
if (data.type === "document") {
const doc = data.data
return (
<ErrorBoundary>
<DocumentCard
index={index}
data={data.data}
data={doc}
width={width}
onClick={handleCardClick}
isSelectionMode={isSelectionMode}
isSelected={doc.id ? selectedDocumentIds.has(doc.id) : false}
onToggleSelection={
doc.id && onToggleSelection
? () => onToggleSelection(doc.id as string)
: undefined
}
/>
</ErrorBoundary>
)
@ -271,7 +327,7 @@ export function MemoriesGrid({
return null
},
[handleCardClick],
[handleCardClick, isSelectionMode, selectedDocumentIds, onToggleSelection],
)
if (!user) {
@ -286,37 +342,140 @@ export function MemoriesGrid({
return (
<div className="relative">
<div id="filter-pills" className="flex flex-wrap gap-1.5 mb-3">
<Button
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
selectedCategories.length === 0 &&
"bg-[#00173C] border-[#2261CA33]",
)}
onClick={handleSelectAll}
>
All
{facetsData?.total !== undefined && (
<span className="ml-1 text-[#737373]">({facetsData.total})</span>
)}
</Button>
{facetsData?.facets.map((facet: DocumentFacet) => (
<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>
))}
{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="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>
<AlertDialog
open={showBulkDeleteConfirm}
onOpenChange={setShowBulkDeleteConfirm}
>
<AlertDialogContent
className={cn(
"border-none bg-[#1B1F24] p-4 gap-4 rounded-[22px] max-w-[400px]",
dmSansClassName(),
)}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<AlertDialogHeader>
<AlertDialogTitle className="text-[#FAFAFA] font-medium">
Delete selected memories?
</AlertDialogTitle>
<AlertDialogDescription className="text-[#737373]">
This will permanently delete {selectedDocumentIds.size}{" "}
{selectedDocumentIds.size === 1 ? "memory" : "memories"}. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-row gap-2 sm:justify-end">
<AlertDialogCancel
className="border-none bg-transparent text-[#737373] hover:bg-[#14161A]/50 hover:text-white rounded-full cursor-pointer"
onClick={() => setShowBulkDeleteConfirm(false)}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700 text-white border-none rounded-[10px] cursor-pointer"
onClick={handleBulkDeleteConfirm}
disabled={isBulkDeleting}
>
{isBulkDeleting ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{error ? (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
@ -411,18 +570,31 @@ function DocumentUrlDisplay({ url }: { url: string }) {
)
}
function isTemporaryId(id: string | null | undefined): boolean {
if (!id) return false
return id.startsWith("temp-") || id.startsWith("temp-file-")
}
const DocumentCard = memo(
({
index: _index,
data: document,
width,
onClick,
isSelectionMode = false,
isSelected = false,
onToggleSelection,
}: {
index: number
data: DocumentWithMemories
width: number
onClick: (document: DocumentWithMemories) => void
isSelectionMode?: boolean
isSelected?: boolean
onToggleSelection?: () => void
}) => {
const canSelect =
!isTemporaryId(document.id) && !isTemporaryId(document.customId)
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
const cardRef = useRef<HTMLButtonElement>(null)
const [ogData, setOgData] = useState<OgData | null>(null)
@ -465,8 +637,12 @@ const DocumentCard = memo(
}
}, [needsOgData, ogData, isLoadingOg, document.url])
useEffect(() => {
if (isSelectionMode) setRotation({ rotateX: 0, rotateY: 0 })
}, [isSelectionMode])
const handleMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!cardRef.current) return
if (isSelectionMode || !cardRef.current) return
const rect = cardRef.current.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
@ -487,12 +663,32 @@ const DocumentCard = memo(
}
return (
<div className="p-2" style={{ width }}>
<div className="p-2 relative" style={{ width }}>
{isSelectionMode && canSelect && (
<button
type="button"
aria-label={isSelected ? "Deselect" : "Select"}
className="absolute top-5 right-5 z-10 flex items-center justify-center cursor-pointer"
onClick={(e) => {
e.stopPropagation()
onToggleSelection?.()
}}
>
{isSelected ? (
<div className="w-3 h-3 rounded-[2.25px] border border-[#369BFD] bg-[#369BFD] flex items-center justify-center">
<CheckIcon className="w-2 h-2 text-white" strokeWidth={3} />
</div>
) : (
<div className="w-3 h-3 rounded-[2.25px] border border-[#737373]" />
)}
</button>
)}
<button
id={document.id ? `document-card-${document.id}` : undefined}
ref={cardRef}
type="button"
className={cn(
"rounded-[22px] bg-[#1B1F24] px-1 space-y-2 pt-1 cursor-pointer w-full",
"rounded-[22px] bg-[#1B1F24] px-1 space-y-2 pt-1 cursor-pointer w-full relative overflow-hidden",
"border-none text-left transition-transform duration-200 ease-out",
document.type === "image" ||
document.metadata?.mimeType?.toString().startsWith("image/")
@ -505,10 +701,15 @@ const DocumentCard = memo(
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
transform: `perspective(1000px) rotateX(${rotation.rotateX}deg) rotateY(${rotation.rotateY}deg)`,
transformStyle: "preserve-3d",
transform: isSelectionMode
? "none"
: `perspective(1000px) rotateX(${rotation.rotateX}deg) rotateY(${rotation.rotateY}deg)`,
transformStyle: isSelectionMode ? undefined : "preserve-3d",
}}
>
{isSelectionMode && isSelected && (
<div className="absolute inset-0 bg-[rgba(75,160,250,0.25)] rounded-[22px] z-1 pointer-events-none" />
)}
<ContentPreview document={document} ogData={ogData} />
{!(
document.type === "image" ||

View file

@ -445,9 +445,7 @@ export const MemoryGraph = ({
variant={variant}
/>
{!isLoading &&
!nodes.some((n) => n.type === "document") &&
children}
{!isLoading && !nodes.some((n) => n.type === "document") && children}
<div
className="w-full h-full relative overflow-hidden touch-none select-none"

View file

@ -29,8 +29,6 @@ type InfiniteQueryData = {
pageParams: number[]
}
type QueryData = DocumentsQueryData | InfiniteQueryData
interface UseDocumentMutationsOptions {
onClose?: () => void
}
@ -140,6 +138,62 @@ function removeDocumentFromQueryData(
return old
}
function removeDocumentsFromQueryData(
old: unknown,
documentIds: Set<string>,
): unknown {
if (!old || typeof old !== "object" || documentIds.size === 0) return old
const data = old as Record<string, unknown>
if ("pages" in data && Array.isArray(data.pages)) {
return {
...data,
pages: data.pages.map((page: unknown) => {
const p = page as Record<string, unknown>
if (!p?.documents || !Array.isArray(p.documents)) return page
const filtered = (p.documents as DocumentWithId[]).filter(
(doc) =>
!documentIds.has(doc.id ?? "") &&
!documentIds.has(doc.customId ?? ""),
)
const removed =
(p.documents as DocumentWithId[]).length - filtered.length
return {
...p,
documents: filtered,
pagination: p.pagination
? {
...(p.pagination as Record<string, unknown>),
totalItems: Math.max(
0,
((p.pagination as Record<string, number>).totalItems ?? 0) -
removed,
),
}
: p.pagination,
}
}),
}
}
if ("documents" in data && Array.isArray(data.documents)) {
const filtered = (data.documents as DocumentWithId[]).filter(
(doc) =>
!documentIds.has(doc.id ?? "") && !documentIds.has(doc.customId ?? ""),
)
const removed =
(data.documents as DocumentWithId[]).length - filtered.length
return {
...data,
documents: filtered,
totalCount: Math.max(0, ((data.totalCount as number) ?? 0) - removed),
}
}
return old
}
async function cancelAndSnapshotQueries(
queryClient: QueryClient,
): Promise<[unknown, unknown][]> {
@ -460,11 +514,50 @@ export function useDocumentMutations({
},
})
const bulkDeleteMutation = useMutation({
mutationFn: async ({ documentIds }: { documentIds: string[] }) => {
const response = await $fetch("@delete/documents/bulk", {
body: { ids: documentIds },
})
if (response.error) {
throw new Error(response.error?.message || "Failed to delete documents")
}
return response.data
},
onMutate: async ({ documentIds }) => {
const previousQueries = await cancelAndSnapshotQueries(queryClient)
const idSet = new Set(documentIds)
queryClient.setQueriesData(
{ queryKey: ["documents-with-memories"] },
(old) => removeDocumentsFromQueryData(old, idSet),
)
return { previousQueries }
},
onError: (error, _variables, context) => {
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
toast.error("Failed to delete documents", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
analytics.documentsBulkDeleted({ count: variables.documentIds.length })
toast.success(
`${variables.documentIds.length} document${variables.documentIds.length === 1 ? "" : "s"} deleted`,
)
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
},
})
return {
noteMutation,
linkMutation,
fileMutation,
updateMutation,
deleteMutation,
bulkDeleteMutation,
}
}

View file

@ -161,6 +161,9 @@ export const analytics = {
documentDeleted: (props: { document_id: string }) =>
safeCapture("document_deleted", props),
documentsBulkDeleted: (props: { count: number }) =>
safeCapture("documents_bulk_deleted", props),
documentEdited: (props: { document_id: string }) =>
safeCapture("document_edited", props),
}

View file

@ -4,6 +4,8 @@ import {
AnalyticsChatResponseSchema,
AnalyticsMemoryResponseSchema,
AnalyticsUsageResponseSchema,
BulkDeleteMemoriesResponseSchema,
BulkDeleteMemoriesSchema,
ConnectionResponseSchema,
CreateProjectSchema,
DeleteProjectResponseSchema,
@ -170,6 +172,12 @@ export const apiSchema = createSchema({
params: z.object({ id: z.string() }),
},
// Bulk delete memories
"@delete/documents/bulk": {
body: BulkDeleteMemoriesSchema,
output: BulkDeleteMemoriesResponseSchema,
},
// Search operations
"@post/search": {
input: SearchRequestSchema,

View file

@ -5,7 +5,8 @@ const SEARCH_MEMORY_SHORTCUT_URL =
const ADD_MEMORY_SHORTCUT_URL =
"https://www.icloud.com/shortcuts/0fd3e855be444845b457f94c78c2c8d9"
const RAYCAST_EXTENSION_URL = "https://www.raycast.com/supermemory/supermemory"
const CHROME_EXTENSION_URL = "https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc"
const CHROME_EXTENSION_URL =
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc"
export {
BIG_DIMENSIONS_NEW,

View file

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