diff --git a/apps/web/components/memory-list-view.tsx b/apps/web/components/memory-list-view.tsx index 2cff96fd..b91e2562 100644 --- a/apps/web/components/memory-list-view.tsx +++ b/apps/web/components/memory-list-view.tsx @@ -1,47 +1,56 @@ -"use client"; +"use client" -import { useIsMobile } from "@hooks/use-mobile"; -import { cn } from "@lib/utils"; -import { Badge } from "@repo/ui/components/badge"; -import { Card, CardContent, CardHeader } from "@repo/ui/components/card"; -import { colors } from "@repo/ui/memory-graph/constants"; -import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"; -import { useVirtualizer } from "@tanstack/react-virtual"; +import { useIsMobile } from "@hooks/use-mobile" +import { cn } from "@lib/utils" +import { Badge } from "@repo/ui/components/badge" +import { Card, CardContent, CardHeader } from "@repo/ui/components/card" import { - Brain, - ExternalLink, - Sparkles, -} from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { z } from "zod"; -import useResizeObserver from "@/hooks/use-resize-observer"; -import { analytics } from "@/lib/analytics"; + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@repo/ui/components/alert-dialog" +import { colors } from "@repo/ui/memory-graph/constants" +import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" +import { useVirtualizer } from "@tanstack/react-virtual" +import { Brain, ExternalLink, Sparkles, Trash2 } from "lucide-react" +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" +import type { z } from "zod" +import useResizeObserver from "@/hooks/use-resize-observer" +import { analytics } from "@/lib/analytics" +import { useDeleteDocument } from "@lib/queries" +import { useProject } from "@/stores" -import { MemoryDetail } from "./memories/memory-detail"; -import { getDocumentIcon } from "@/lib/document-icon"; -import { formatDate, getSourceUrl } from "./memories"; +import { MemoryDetail } from "./memories/memory-detail" +import { getDocumentIcon } from "@/lib/document-icon" +import { formatDate, getSourceUrl } from "./memories" -type DocumentsResponse = z.infer; -type DocumentWithMemories = DocumentsResponse["documents"][0]; +type DocumentsResponse = z.infer +type DocumentWithMemories = DocumentsResponse["documents"][0] interface MemoryListViewProps { - children?: React.ReactNode; - documents: DocumentWithMemories[]; - isLoading: boolean; - isLoadingMore: boolean; - error: Error | null; - totalLoaded: number; - hasMore: boolean; - loadMoreDocuments: () => Promise; + children?: React.ReactNode + documents: DocumentWithMemories[] + isLoading: boolean + isLoadingMore: boolean + error: Error | null + totalLoaded: number + hasMore: boolean + loadMoreDocuments: () => Promise } const GreetingMessage = memo(() => { const getGreeting = () => { - const hour = new Date().getHours(); - if (hour < 12) return "Good morning"; - if (hour < 17) return "Good afternoon"; - return "Good evening"; - }; + const hour = new Date().getHours() + if (hour < 12) return "Good morning" + if (hour < 17) return "Good afternoon" + return "Good evening" + } return (
@@ -57,28 +66,30 @@ const GreetingMessage = memo(() => {

- ); -}); + ) +}) const DocumentCard = memo( ({ document, onOpenDetails, + onDelete, }: { - document: DocumentWithMemories; - onOpenDetails: (document: DocumentWithMemories) => void; + document: DocumentWithMemories + onOpenDetails: (document: DocumentWithMemories) => void + onDelete: (document: DocumentWithMemories) => void }) => { - const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten); + const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten) const forgottenMemories = document.memoryEntries.filter( (m) => m.isForgotten, - ); + ) return ( { - analytics.documentCardClicked(); - onOpenDetails(document); + analytics.documentCardClicked() + onOpenDetails(document) }} style={{ backgroundColor: colors.document.primary, @@ -101,9 +112,9 @@ const DocumentCard = memo( + + + + Delete Document + + Are you sure you want to delete this document and all its + related memories? This action cannot be undone. + + + + { + e.stopPropagation() + }} + > + Cancel + + { + e.stopPropagation() + onDelete(document) + }} + > + Delete + + + + - ); + ) }, -); +) export const MemoryListView = ({ children, @@ -170,29 +227,38 @@ export const MemoryListView = ({ hasMore, loadMoreDocuments, }: MemoryListViewProps) => { - const [selectedSpace, _] = useState("all"); + const [selectedSpace, _] = useState("all") const [selectedDocument, setSelectedDocument] = - useState(null); - const [isDetailOpen, setIsDetailOpen] = useState(false); - const parentRef = useRef(null); - const containerRef = useRef(null); - const isMobile = useIsMobile(); + useState(null) + const [isDetailOpen, setIsDetailOpen] = useState(false) + const parentRef = useRef(null) + const containerRef = useRef(null) + const isMobile = useIsMobile() + const { selectedProject } = useProject() + const deleteDocumentMutation = useDeleteDocument(selectedProject) - const gap = 14; + const gap = 14 - const { width: containerWidth } = useResizeObserver(containerRef); - const columnWidth = isMobile ? containerWidth : 320; + const handleDeleteDocument = useCallback( + (document: DocumentWithMemories) => { + deleteDocumentMutation.mutate(document.id) + }, + [deleteDocumentMutation], + ) + + const { width: containerWidth } = useResizeObserver(containerRef) + const columnWidth = isMobile ? containerWidth : 320 const columns = Math.max( 1, Math.floor((containerWidth + gap) / (columnWidth + gap)), - ); + ) // Filter documents based on selected space const filteredDocuments = useMemo(() => { - if (!documents) return []; + if (!documents) return [] if (selectedSpace === "all") { - return documents; + return documents } return documents @@ -203,44 +269,44 @@ export const MemoryListView = ({ (memory.spaceContainerTag ?? memory.spaceId) === selectedSpace, ), })) - .filter((doc) => doc.memoryEntries.length > 0); - }, [documents, selectedSpace]); + .filter((doc) => doc.memoryEntries.length > 0) + }, [documents, selectedSpace]) const handleOpenDetails = useCallback((document: DocumentWithMemories) => { - analytics.memoryDetailOpened(); - setSelectedDocument(document); - setIsDetailOpen(true); - }, []); + analytics.memoryDetailOpened() + setSelectedDocument(document) + setIsDetailOpen(true) + }, []) const handleCloseDetails = useCallback(() => { - setIsDetailOpen(false); - setTimeout(() => setSelectedDocument(null), 300); - }, []); + setIsDetailOpen(false) + setTimeout(() => setSelectedDocument(null), 300) + }, []) const virtualItems = useMemo(() => { - const items = []; + const items = [] for (let i = 0; i < filteredDocuments.length; i += columns) { - items.push(filteredDocuments.slice(i, i + columns)); + items.push(filteredDocuments.slice(i, i + columns)) } - return items; - }, [filteredDocuments, columns]); + return items + }, [filteredDocuments, columns]) const virtualizer = useVirtualizer({ count: virtualItems.length, getScrollElement: () => parentRef.current, overscan: 5, estimateSize: () => 200, - }); + }) useEffect(() => { - const [lastItem] = [...virtualizer.getVirtualItems()].reverse(); + const [lastItem] = [...virtualizer.getVirtualItems()].reverse() if (!lastItem || !hasMore || isLoadingMore) { - return; + return } if (lastItem.index >= virtualItems.length - 1) { - loadMoreDocuments(); + loadMoreDocuments() } }, [ hasMore, @@ -248,15 +314,15 @@ export const MemoryListView = ({ loadMoreDocuments, virtualizer.getVirtualItems(), virtualItems.length, - ]); + ]) // Always render with consistent structure return ( <>
{error ? (
@@ -301,8 +367,8 @@ export const MemoryListView = ({ }} > {virtualizer.getVirtualItems().map((virtualRow) => { - const rowItems = virtualItems[virtualRow.index]; - if (!rowItems) return null; + const rowItems = virtualItems[virtualRow.index] + if (!rowItems) return null return (
))}
- ); + ) })}
@@ -355,5 +422,5 @@ export const MemoryListView = ({ isMobile={isMobile} /> - ); -}; + ) +} diff --git a/packages/lib/queries.ts b/packages/lib/queries.ts index 36338445..0902f378 100644 --- a/packages/lib/queries.ts +++ b/packages/lib/queries.ts @@ -1,5 +1,12 @@ -import { useQuery } from "@tanstack/react-query" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import type { useCustomer } from "autumn-js/react" +import { $fetch } from "./api" +import { toast } from "sonner" +import type { DocumentsWithMemoriesResponseSchema } from "../validation/api" +import type { z } from "zod" + +type DocumentsResponse = z.infer +type DocumentWithMemories = DocumentsResponse["documents"][0] export const fetchSubscriptionStatus = ( autumn: ReturnType, @@ -84,3 +91,68 @@ export const fetchProProduct = (autumn: ReturnType) => staleTime: 30 * 1000, // 30 seconds gcTime: 5 * 60 * 1000, // 5 minutes }) + +export const useDeleteDocument = (selectedProject: string) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (documentId: string) => { + // context for LLM: delete/memories/:documentId is documents delete endpoint not memories delete endpoint + const response = await $fetch(`@delete/memories/${documentId}`) + if (response.error) { + throw new Error(response.error?.message || "Failed to delete document") + } + return response.data + }, + onMutate: async (documentId: string) => { + await queryClient.cancelQueries({ + queryKey: ["documents-with-memories", selectedProject], + }) + + const previousData = queryClient.getQueryData([ + "documents-with-memories", + selectedProject, + ]) + + queryClient.setQueryData( + ["documents-with-memories", selectedProject], + (old: unknown) => { + if (!old || typeof old !== "object") return old + const typedOld = old as { + pages?: Array<{ documents?: DocumentWithMemories[] }> + } + return { + ...typedOld, + pages: typedOld.pages?.map((page) => ({ + ...page, + documents: page.documents?.filter( + (doc: DocumentWithMemories) => doc.id !== documentId, + ), + })), + } + }, + ) + + return { previousData } + }, + onSuccess: () => { + toast.success("Memory deleted successfully") + }, + onError: (error, _documentId, context) => { + if (context?.previousData) { + queryClient.setQueryData( + ["documents-with-memories", selectedProject], + context.previousData, + ) + } + toast.error("Failed to delete memory", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: ["documents-with-memories", selectedProject], + }) + }, + }) +}