Merge branch 'main' into 03-10-feat_empty_state_action_for_new_spaces

This commit is contained in:
Mahesh Sanikommu 2026-03-11 09:20:22 +05:30 committed by GitHub
commit f015053a2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 383 additions and 13 deletions

View file

@ -6,7 +6,10 @@ on:
jobs:
claude-review:
if: github.event.pull_request.draft == false
if: |
github.event.pull_request.draft == false &&
github.actor != 'graphite-app[bot]' &&
github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:

View file

@ -155,13 +155,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[]
@ -387,6 +440,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

@ -32,6 +32,17 @@ 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,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@ui/components/alert-dialog"
import { CheckIcon, Trash2Icon, XIcon } from "lucide-react"
// Document category type
type DocumentCategory =
@ -94,6 +105,14 @@ interface NovaEmptyStateProps {
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
emptyStateProps?: NovaEmptyStateProps
@ -102,10 +121,19 @@ interface MemoriesGridProps {
export function MemoriesGrid({
isChatOpen,
onOpenDocument,
isSelectionMode = false,
selectedDocumentIds = new Set(),
onEnterSelectionMode,
onToggleSelection,
onClearSelection,
onSelectAllVisible,
onBulkDelete,
isBulkDeleting = false,
quickNoteProps,
highlightsProps,
emptyStateProps,
}: MemoriesGridProps) {
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const { user } = useAuth()
const { effectiveContainerTags } = useProject()
const isMobile = useIsMobile()
@ -253,11 +281,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,
@ -269,13 +317,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>
)
@ -283,7 +339,7 @@ export function MemoriesGrid({
return null
},
[handleCardClick],
[handleCardClick, isSelectionMode, selectedDocumentIds, onToggleSelection],
)
if (!user) {
@ -303,6 +359,11 @@ export function MemoriesGrid({
<div className="relative">
{!isEmpty && (
<div id="filter-pills" className="flex flex-wrap gap-1.5 mb-3">
<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(),
@ -334,6 +395,105 @@ export function MemoriesGrid({
))}
</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">
@ -435,18 +595,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)
@ -489,8 +662,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
@ -511,12 +688,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/")
@ -529,10 +726,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

@ -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,