feat: change "All Spaces" to "Nova Spaces" with multi-select support (#731)

### Implemented nova spaces multi-select

  ##### Summary
  - Renamed "All Spaces" to "Nova Spaces" - now filters to only nova content (sm_project_*)
  - Added multi-select spaces support via "Select Spaces" modal
  - Projects API now returns `{ nova, developer }` instead of `{ projects }`

  ##### Changes
  - `selectedProject` → `selectedProjects[]` (array-based selection)
  - New `SelectSpacesModal` component for picking multiple spaces
  - Selected spaces appear at top in modal
  - Search works by containerTag
  - Graphs filter by selected spaces
This commit is contained in:
Prasanna721
2026-02-13 21:34:58 +00:00
parent d7a1ef7240
commit 15613c2421
19 changed files with 928 additions and 409 deletions
+6 -2
View File
@@ -63,7 +63,7 @@ function ViewErrorFallback() {
export default function NewPage() {
const isMobile = useIsMobile()
const { user, session } = useAuth()
const { selectedProject } = useProject()
const { selectedProject, isNovaSpaces, novaContainerTags } = useProject()
const { viewMode, setViewMode } = useViewMode()
const queryClient = useQueryClient()
@@ -88,7 +88,10 @@ export default function NewPage() {
const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam)
const [searchPrefill, setSearchPrefill] = useQueryState("q", qParam)
const [docId, setDocId] = useQueryState("doc", docParam)
const [isFullscreen, setIsFullscreen] = useQueryState("fullscreen", fullscreenParam)
const [isFullscreen, setIsFullscreen] = useQueryState(
"fullscreen",
fullscreenParam,
)
const [isChatOpen, setIsChatOpen] = useQueryState("chat", chatParam)
// Ephemeral local state (not worth URL-encoding)
@@ -399,6 +402,7 @@ export default function NewPage() {
if (!open) setSearchPrefill("")
}}
projectId={selectedProject}
novaContainerTags={isNovaSpaces ? novaContainerTags : undefined}
onOpenDocument={handleOpenDocument}
onAddMemory={() => {
analytics.addDocumentModalOpened()
+4 -2
View File
@@ -155,7 +155,7 @@ export function ConnectAIModal({
if (response.error) {
throw new Error(response.error?.message || "Failed to load projects")
}
return response.data?.projects || []
return (response.data?.projects || []) as Project[]
},
staleTime: 30 * 1000,
})
@@ -803,7 +803,9 @@ export function ConnectAIModal({
className="bg-input border-border text-foreground"
id="mcpUrl"
onBlur={handleBlur}
onChange={(e) => handleChange(e.target.value)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>,
) => handleChange(e.target.value)}
placeholder="https://mcp.supermemory.ai/your-user-id/sse"
value={state.value}
/>
+72 -38
View File
@@ -105,7 +105,9 @@ export function AddDocument({
const [addParam, setAddParam] = useQueryState("add", addDocumentParam)
const activeTab: TabType = addParam ?? "note"
const setActiveTab = useCallback(
(tab: TabType) => { setAddParam(tab) },
(tab: TabType) => {
setAddParam(tab)
},
[setAddParam],
)
const { selectedProject: globalSelectedProject } = useProject()
@@ -199,20 +201,23 @@ export function AddDocument({
setFileData(data)
}, [])
// Button click handler
const handleButtonClick = () => {
if (activeTab === "note") {
handleNoteSubmit(noteContent)
} else if (activeTab === "link") {
handleLinkSubmit(linkData)
} else if (activeTab === "file") {
if (fileData.file) {
handleFileSubmit(
fileData as { file: File; title: string; description: string },
)
} else {
toast.error("Please select a file")
}
switch (activeTab) {
case "note":
handleNoteSubmit(noteContent)
break
case "link":
handleLinkSubmit(linkData)
break
case "file":
if (fileData.file) {
handleFileSubmit(
fileData as { file: File; title: string; description: string },
)
} else {
toast.error("Please select a file")
}
break
}
}
@@ -250,17 +255,27 @@ export function AddDocument({
</div>
{!isMobile && (
<div
data-testid="usage-counter"
className="flex flex-col gap-3 mr-4"
>
<div data-testid="usage-counter" className="flex flex-col gap-3 mr-4">
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<span className={cn("text-[#FAFAFA] text-sm font-medium", dmSansClassName())}>
<span
className={cn(
"text-[#FAFAFA] text-sm font-medium",
dmSansClassName(),
)}
>
Credits
</span>
<span className={cn("text-sm font-medium", hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]", dmSansClassName())}>
{isLoadingUsage ? "…" : `${tokensToCredits(tokensUsed)} / ${tokensToCredits(tokensLimit)}`}
<span
className={cn(
"text-sm font-medium",
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
dmSansClassName(),
)}
>
{isLoadingUsage
? "…"
: `${tokensToCredits(tokensUsed)} / ${tokensToCredits(tokensLimit)}`}
</span>
</div>
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
@@ -268,11 +283,12 @@ export function AddDocument({
className="h-full rounded-[40px]"
style={{
width: `${tokensPercent}%`,
background: tokensPercent > 80
? "#ef4444"
: hasPaidPlan
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
: "#0054AD",
background:
tokensPercent > 80
? "#ef4444"
: hasPaidPlan
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
: "#0054AD",
}}
/>
</div>
@@ -280,11 +296,24 @@ export function AddDocument({
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<span className={cn("text-[#FAFAFA] text-sm font-medium", dmSansClassName())}>
<span
className={cn(
"text-[#FAFAFA] text-sm font-medium",
dmSansClassName(),
)}
>
Search Queries
</span>
<span className={cn("text-sm font-medium", hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]", dmSansClassName())}>
{isLoadingUsage ? "…" : `${formatUsageNumber(searchesUsed)} / ${formatUsageNumber(searchesLimit)}`}
<span
className={cn(
"text-sm font-medium",
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
dmSansClassName(),
)}
>
{isLoadingUsage
? "…"
: `${formatUsageNumber(searchesUsed)} / ${formatUsageNumber(searchesLimit)}`}
</span>
</div>
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
@@ -292,11 +321,12 @@ export function AddDocument({
className="h-full rounded-[40px]"
style={{
width: `${searchesPercent}%`,
background: searchesPercent > 80
? "#ef4444"
: hasPaidPlan
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
: "#0054AD",
background:
searchesPercent > 80
? "#ef4444"
: hasPaidPlan
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
: "#0054AD",
}}
/>
</div>
@@ -327,8 +357,10 @@ export function AddDocument({
dmSansClassName(),
)}
style={{
background: "linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow: "1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
>
{isUpgrading ? (
@@ -387,8 +419,10 @@ export function AddDocument({
>
{!isMobile && (
<SpaceSelector
value={localSelectedProject}
onValueChange={setLocalSelectedProject}
selectedProjects={[localSelectedProject]}
onValueChange={(projects) =>
setLocalSelectedProject(projects[0] ?? localSelectedProject)
}
variant="insideOut"
/>
)}
@@ -12,14 +12,7 @@ import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useIsMobile } from "@hooks/use-mobile"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import {
SearchIcon,
Settings,
Home,
Plus,
Code2,
Loader2,
} from "lucide-react"
import { SearchIcon, Settings, Home, Plus, Code2, Loader2 } from "lucide-react"
import { DocumentIcon } from "@/components/new/document-icon"
import { $fetch } from "@lib/api"
@@ -28,7 +21,13 @@ type DocumentWithMemories = DocumentsResponse["documents"][0]
type SearchResult = z.infer<typeof SearchResponseSchema>["results"][number]
type PaletteItem =
| { kind: "action"; id: string; label: string; icon: React.ReactNode; action: () => void }
| {
kind: "action"
id: string
label: string
icon: React.ReactNode
action: () => void
}
| { kind: "document"; doc: DocumentWithMemories }
| { kind: "search-result"; result: SearchResult }
@@ -36,6 +35,7 @@ interface DocumentsCommandPaletteProps {
open: boolean
onOpenChange: (open: boolean) => void
projectId: string
novaContainerTags?: string[]
onOpenDocument: (document: DocumentWithMemories) => void
onAddMemory?: () => void
onOpenIntegrations?: () => void
@@ -46,6 +46,7 @@ export function DocumentsCommandPalette({
open,
onOpenChange,
projectId,
novaContainerTags,
onOpenDocument,
onAddMemory,
onOpenIntegrations,
@@ -64,12 +65,15 @@ export function DocumentsCommandPalette({
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const abortRef = useRef<AbortController | null>(null)
const close = useCallback((then?: () => void) => {
onOpenChange(false)
setSearch("")
setSearchResults([])
if (then) setTimeout(then, 0)
}, [onOpenChange])
const close = useCallback(
(then?: () => void) => {
onOpenChange(false)
setSearch("")
setSearchResults([])
if (then) setTimeout(then, 0)
},
[onOpenChange],
)
const actions: PaletteItem[] = [
{
@@ -87,22 +91,32 @@ export function DocumentsCommandPalette({
action: () => close(() => router.push("/settings")),
},
...(onAddMemory
? [{
kind: "action" as const,
id: "add-memory",
label: "Add Memory",
icon: <Plus className="size-4 text-[#737373]" />,
action: () => { close(); onAddMemory() },
}]
? [
{
kind: "action" as const,
id: "add-memory",
label: "Add Memory",
icon: <Plus className="size-4 text-[#737373]" />,
action: () => {
close()
onAddMemory()
},
},
]
: []),
...(onOpenIntegrations
? [{
kind: "action" as const,
id: "integrations",
label: "Open Integrations",
icon: <Code2 className="size-4 text-[#737373]" />,
action: () => { close(); onOpenIntegrations() },
}]
? [
{
kind: "action" as const,
id: "integrations",
label: "Open Integrations",
icon: <Code2 className="size-4 text-[#737373]" />,
action: () => {
close()
onOpenIntegrations()
},
},
]
: []),
]
@@ -145,7 +159,8 @@ export function DocumentsCommandPalette({
body: {
q: search.trim(),
limit: 10,
containerTags: projectId ? [projectId] : undefined,
containerTags:
novaContainerTags ?? (projectId ? [projectId] : undefined),
includeSummary: true,
},
signal: controller.signal,
@@ -163,7 +178,7 @@ export function DocumentsCommandPalette({
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [search, projectId])
}, [search, projectId, novaContainerTags])
// Build the item list
const hasQuery = search.trim().length > 0
@@ -175,10 +190,12 @@ export function DocumentsCommandPalette({
}
const q = search.toLowerCase()
for (const a of actions) {
if (a.kind === "action" && a.label.toLowerCase().includes(q)) items.push(a)
if (a.kind === "action" && a.label.toLowerCase().includes(q))
items.push(a)
}
} else {
for (const doc of cachedDocs.slice(0, 10)) items.push({ kind: "document", doc })
for (const doc of cachedDocs.slice(0, 10))
items.push({ kind: "document", doc })
for (const a of actions) items.push(a)
}
@@ -211,7 +228,8 @@ export function DocumentsCommandPalette({
createdAt: item.result.createdAt as unknown as string,
updatedAt: item.result.updatedAt as unknown as string,
url: (item.result.metadata?.url as string) ?? null,
content: item.result.content ?? item.result.chunks?.[0]?.content ?? null,
content:
item.result.content ?? item.result.chunks?.[0]?.content ?? null,
summary: item.result.summary ?? null,
} as unknown as DocumentWithMemories)
close()
@@ -264,20 +282,15 @@ export function DocumentsCommandPalette({
)
}
const title =
item.kind === "document" ? item.doc.title : item.result.title
const type =
item.kind === "document" ? item.doc.type : item.result.type
const title = item.kind === "document" ? item.doc.title : item.result.title
const type = item.kind === "document" ? item.doc.type : item.result.type
const url =
item.kind === "document"
? item.doc.url
: (item.result.metadata?.url as string) ?? null
: ((item.result.metadata?.url as string) ?? null)
const date =
item.kind === "document"
? item.doc.createdAt
: item.result.createdAt
const key =
item.kind === "document" ? item.doc.id : item.result.documentId
item.kind === "document" ? item.doc.createdAt : item.result.createdAt
const key = item.kind === "document" ? item.doc.id : item.result.documentId
const snippet =
item.kind === "search-result"
? item.result.chunks?.find((c) => c.isRelevant)?.content
@@ -391,11 +404,14 @@ export function DocumentsCommandPalette({
.filter(({ item }) => item.kind === "action")
.map(({ item, globalIndex }) => renderItem(item, globalIndex))}
{hasQuery && !isSearching && searchResults.length === 0 && items.every((i) => i.kind === "action") && (
<div className="flex items-center justify-center py-12">
<p className="text-[#737373] text-sm">No results found</p>
</div>
)}
{hasQuery &&
!isSearching &&
searchResults.length === 0 &&
items.every((i) => i.kind === "action") && (
<div className="flex items-center justify-center py-12">
<p className="text-[#737373] text-sm">No results found</p>
</div>
)}
</div>
<div className="flex items-center justify-between px-4 py-2.5 text-[11px] text-[#737373]">
@@ -17,13 +17,14 @@ interface GraphLayoutViewProps {
}
export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
const { selectedProject } = useProject()
const { effectiveContainerTags } = useProject()
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const [isShareModalOpen, setIsShareModalOpen] = useQueryState("share", shareParam)
const [isShareModalOpen, setIsShareModalOpen] = useQueryState(
"share",
shareParam,
)
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerTags = selectedProject ? [selectedProject] : undefined
const handleShare = useCallback(() => {
setIsShareModalOpen(true)
}, [setIsShareModalOpen])
@@ -37,7 +38,7 @@ export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
{/* Full-width graph */}
<div className="absolute inset-0">
<MemoryGraph
containerTags={containerTags}
containerTags={effectiveContainerTags}
variant="consumer"
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isChatOpen}
+5 -7
View File
@@ -31,7 +31,6 @@ import {
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { authClient } from "@lib/auth"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { useProject } from "@/stores"
import { useRouter } from "next/navigation"
import Link from "next/link"
@@ -51,8 +50,7 @@ interface HeaderProps {
export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
const { user } = useAuth()
const { selectedProject } = useProject()
const { switchProject } = useProjectMutations()
const { selectedProjects, setSelectedProjects } = useProject()
const router = useRouter()
const isMobile = useIsMobile()
const { resetOrgOnboarded } = useOrgOnboarding()
@@ -144,8 +142,8 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
<div className="self-stretch w-px bg-[#FFFFFF33] hidden md:block" />
{!isMobile && (
<SpaceSelector
value={selectedProject}
onValueChange={switchProject}
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
showChevron
enableDelete
/>
@@ -196,8 +194,8 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
{isMobile ? (
<>
<SpaceSelector
value={selectedProject}
onValueChange={switchProject}
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
showChevron
enableDelete
compact
+4 -2
View File
@@ -56,8 +56,10 @@ export function MCPModal({
<DialogFooter className="justify-between!">
<div className="flex items-center gap-2">
<SpaceSelector
value={selectedProject}
onValueChange={switchProject}
selectedProjects={[selectedProject]}
onValueChange={(projects) =>
switchProject(projects[0] ?? selectedProject)
}
variant="insideOut"
/>
<Button
+10 -8
View File
@@ -96,7 +96,7 @@ export function MemoriesGrid({
highlightsProps,
}: MemoriesGridProps) {
const { user } = useAuth()
const { selectedProject } = useProject()
const { selectedProjects, effectiveContainerTags } = useProject()
const isMobile = useIsMobile()
const [selectedCategories, setSelectedCategories] = useQueryState(
"categories",
@@ -104,11 +104,11 @@ export function MemoriesGrid({
)
const { data: facetsData } = useQuery({
queryKey: ["document-facets", selectedProject],
queryKey: ["document-facets", effectiveContainerTags],
queryFn: async (): Promise<FacetsResponse> => {
const response = await $fetch("@post/documents/documents/facets", {
body: {
containerTags: selectedProject ? [selectedProject] : undefined,
containerTags: effectiveContainerTags,
},
disableValidation: true,
})
@@ -131,7 +131,11 @@ export function MemoriesGrid({
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<DocumentsResponse, Error>({
queryKey: ["documents-with-memories", selectedProject, selectedCategories],
queryKey: [
"documents-with-memories",
effectiveContainerTags,
selectedCategories,
],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
const response = await $fetch("@post/documents/documents", {
@@ -140,7 +144,7 @@ export function MemoriesGrid({
limit: PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags: selectedProject ? [selectedProject] : undefined,
containerTags: effectiveContainerTags,
categories:
selectedCategories.length > 0 ? selectedCategories : undefined,
},
@@ -346,9 +350,7 @@ export function MemoriesGrid({
)}
<div className="w-[216px] shrink-0">
<GraphCard
containerTags={
selectedProject ? [selectedProject] : undefined
}
containerTags={effectiveContainerTags}
width={200}
height={220}
/>
@@ -0,0 +1,226 @@
"use client"
import { useState, useMemo, useEffect } from "react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon, Search, Check } from "lucide-react"
import { Button } from "@ui/components/button"
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
import type { ContainerTagListType } from "@repo/lib/types"
interface SelectSpacesModalProps {
isOpen: boolean
onClose: () => void
selectedProjects: string[]
onApply: (selected: string[]) => void
projects: ContainerTagListType[]
}
export function SelectSpacesModal({
isOpen,
onClose,
selectedProjects,
onApply,
projects,
}: SelectSpacesModalProps) {
const [searchQuery, setSearchQuery] = useState("")
const [localSelection, setLocalSelection] =
useState<string[]>(selectedProjects)
useEffect(() => {
if (isOpen) {
setLocalSelection(selectedProjects)
}
}, [isOpen, selectedProjects])
const handleOpenChange = (open: boolean) => {
if (!open) {
onClose()
setSearchQuery("")
setLocalSelection(selectedProjects)
}
}
const handleToggle = (containerTag: string) => {
setLocalSelection((prev) => {
if (prev.includes(containerTag)) {
return prev.filter((tag) => tag !== containerTag)
}
return [...prev, containerTag]
})
}
const handleApply = () => {
onApply(localSelection)
setSearchQuery("")
}
const handleCancel = () => {
onClose()
setSearchQuery("")
setLocalSelection(selectedProjects)
}
const filteredProjects = useMemo(() => {
const defaultSpace = {
id: "default",
name: "My Space",
emoji: "📁",
containerTag: DEFAULT_PROJECT_ID,
}
const allSpaces = [
defaultSpace,
...projects.filter((p) => p.containerTag !== DEFAULT_PROJECT_ID),
]
let result = allSpaces
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase()
result = allSpaces.filter(
(p) =>
p.containerTag.toLowerCase().includes(query) ||
p.name?.toLowerCase().includes(query),
)
}
return result.sort((a, b) => {
const aSelected = localSelection.includes(a.containerTag)
const bSelected = localSelection.includes(b.containerTag)
if (aSelected && !bSelected) return -1
if (!aSelected && bSelected) return 1
return 0
})
}, [projects, searchQuery, localSelection])
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
className={cn(
"w-[90%]! max-w-[500px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
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",
}}
showCloseButton={false}
>
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start gap-4">
<div className="pl-1 space-y-1 flex-1">
<p
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Select Spaces
</p>
<p className="text-[#737373] font-medium text-[16px] leading-[1.35]">
Choose one or more spaces to filter your memories
</p>
</div>
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-[#737373]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search spaces..."
className={cn(
"w-full bg-[#14161A] border border-[rgba(82,89,102,0.2)] pl-10 pr-4 py-3 rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)]",
dmSansClassName(),
)}
style={{
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}}
autoFocus
/>
</div>
<div className="max-h-[300px] overflow-y-auto space-y-1 scrollbar-thin">
{filteredProjects.length === 0 ? (
<p className="text-center text-[#737373] text-sm py-4">
No spaces found
</p>
) : (
filteredProjects.map((project) => {
const isSelected = localSelection.includes(project.containerTag)
return (
<button
key={project.containerTag}
type="button"
onClick={() => handleToggle(project.containerTag)}
className={cn(
"flex items-center gap-3 w-full px-3 py-2.5 rounded-[12px] cursor-pointer transition-colors text-left",
isSelected
? "bg-[#14161A] border border-[rgba(82,89,102,0.3)]"
: "bg-transparent border border-transparent hover:bg-[#14161A]/50",
)}
>
<div
className={cn(
"w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors",
isSelected
? "bg-blue-500 border-blue-500"
: "border-[#737373]",
)}
>
{isSelected && <Check className="size-3 text-white" />}
</div>
<span className="text-lg">{project.emoji || "📁"}</span>
<span className="text-[#fafafa] text-sm font-medium truncate flex-1">
{project.name ?? project.containerTag}
</span>
</button>
)
})
)}
</div>
<div className="flex items-center justify-between">
<p className="text-[#737373] text-sm">
{localSelection.length === 0
? "No spaces selected (showing all)"
: `${localSelection.length} space${localSelection.length > 1 ? "s" : ""} selected`}
</p>
<div className="flex items-center gap-[22px]">
<button
type="button"
onClick={handleCancel}
className={cn(
"text-[#737373] font-medium text-[14px] cursor-pointer transition-colors hover:text-[#999]",
dmSansClassName(),
)}
>
Cancel
</button>
<Button
variant="insideOut"
onClick={handleApply}
className="px-4 py-[10px] rounded-full"
>
Apply
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+176 -78
View File
@@ -3,22 +3,36 @@
import { useState, useMemo } from "react"
import { cn } from "@lib/utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { $fetch } from "@repo/lib/api"
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
import { useQuery } from "@tanstack/react-query"
import { ChevronsLeftRight, Plus, Trash2, XIcon, Loader2 } from "lucide-react"
import type { Project } from "@repo/lib/types"
import {
ChevronsLeftRight,
Plus,
Trash2,
XIcon,
Loader2,
Globe,
Layers,
} from "lucide-react"
import type { ContainerTagListType } from "@repo/lib/types"
import { AddSpaceModal } from "./add-space-modal"
import { SelectSpacesModal } from "./select-spaces-modal"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { useContainerTags } from "@/hooks/use-container-tags"
import { motion } from "motion/react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from "@repo/ui/components/dialog"
import {
Select,
SelectContent,
@@ -30,8 +44,8 @@ import { Button } from "@repo/ui/components/button"
import { analytics } from "@/lib/analytics"
export interface SpaceSelectorProps {
value: string
onValueChange: (containerTag: string) => void
selectedProjects: string[]
onValueChange: (containerTags: string[]) => void
variant?: "default" | "insideOut"
showChevron?: boolean
triggerClassName?: string
@@ -47,7 +61,7 @@ const triggerVariants = {
}
export function SpaceSelector({
value,
selectedProjects,
onValueChange,
variant = "default",
showChevron = false,
@@ -59,6 +73,7 @@ export function SpaceSelector({
}: SpaceSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showSelectSpacesModal, setShowSelectSpacesModal] = useState(false)
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean
project: { id: string; name: string; containerTag: string } | null
@@ -73,39 +88,65 @@ export function SpaceSelector({
const { deleteProjectMutation } = useProjectMutations()
const { data: projects = [], isLoading } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await $fetch("@get/projects")
const { allProjects, novaProjects, isLoading } = useContainerTags()
if (response.error) {
throw new Error(response.error?.message || "Failed to load projects")
}
const isNovaSpaces = selectedProjects.length === 0
return response.data?.projects || []
},
staleTime: 30 * 1000,
})
const selectedProject = useMemo(() => {
if (value === DEFAULT_PROJECT_ID) return { name: "My Space", emoji: "📁" }
const found = projects.find((p: Project) => p.containerTag === value)
return found
? { name: found.name, emoji: found.emoji }
: { name: value, emoji: undefined }
}, [projects, value])
const selectedProjectName = selectedProject.name
const selectedProjectEmoji = selectedProject.emoji || "📁"
const handleSelect = (containerTag: string) => {
if (containerTag !== value) {
analytics.spaceSwitched({ space_id: containerTag })
const displayInfo = useMemo(() => {
if (isNovaSpaces) {
return { name: "Nova Spaces", emoji: null, isMultiple: false }
}
onValueChange(containerTag)
if (selectedProjects.length === 1) {
const containerTag = selectedProjects[0]
if (containerTag === DEFAULT_PROJECT_ID) {
return { name: "My Space", emoji: "📁", isMultiple: false }
}
const found = allProjects.find(
(p: ContainerTagListType) => p.containerTag === containerTag,
)
return {
name: found?.name || containerTag,
emoji: found?.emoji || "📁",
isMultiple: false,
}
}
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)
}
const handleSelectSingleSpace = (containerTag: string) => {
analytics.spaceSwitched({ space_id: containerTag })
onValueChange([containerTag])
setIsOpen(false)
}
const handleOpenSelectSpaces = () => {
setIsOpen(false)
setShowSelectSpacesModal(true)
}
const handleSelectSpacesApply = (selected: string[]) => {
if (selected.length > 0) {
analytics.spaceSwitched({
space_id:
selected.length === 1 ? (selected[0] ?? "unknown") : "multiple",
})
}
onValueChange(selected)
setShowSelectSpacesModal(false)
}
const handleNewSpace = () => {
setIsOpen(false)
setShowCreateDialog(true)
@@ -161,14 +202,14 @@ export function SpaceSelector({
}
const availableTargetProjects = useMemo(() => {
const filtered = projects.filter(
(p: Project) =>
const filtered = novaProjects.filter(
(p: ContainerTagListType) =>
p.id !== deleteDialog.project?.id &&
p.containerTag !== deleteDialog.project?.containerTag,
)
const defaultProject = projects.find(
(p: Project) => p.containerTag === DEFAULT_PROJECT_ID,
const defaultProject = novaProjects.find(
(p: ContainerTagListType) => p.containerTag === DEFAULT_PROJECT_ID,
)
const isDefaultProjectBeingDeleted =
@@ -176,7 +217,7 @@ export function SpaceSelector({
if (defaultProject && !isDefaultProjectBeingDeleted) {
const defaultProjectIncluded = filtered.some(
(p: Project) => p.containerTag === DEFAULT_PROJECT_ID,
(p: ContainerTagListType) => p.containerTag === DEFAULT_PROJECT_ID,
)
if (!defaultProjectIncluded) {
return [defaultProject, ...filtered]
@@ -184,7 +225,7 @@ export function SpaceSelector({
}
return filtered
}, [projects, deleteDialog.project])
}, [novaProjects, deleteDialog.project])
return (
<>
@@ -193,18 +234,24 @@ export function SpaceSelector({
<button
type="button"
className={cn(
"flex items-center gap-2 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
"flex items-center gap-2 cursor-pointer transition-colors focus:outline-none focus-visible:outline-none",
triggerVariants[variant],
dmSansClassName(),
triggerClassName,
)}
>
<span className="text-sm font-bold tracking-[-0.98px]">
{selectedProjectEmoji}
</span>
{isNovaSpaces ? (
<Globe className="size-4 text-white" />
) : displayInfo.isMultiple ? (
<Layers className="size-4 text-white" />
) : (
<span className="text-sm font-bold tracking-[-0.98px]">
{displayInfo.emoji}
</span>
)}
{!compact && (
<span className="text-sm font-medium text-white">
{isLoading ? "..." : selectedProjectName}
{isLoading ? "..." : displayInfo.name}
</span>
)}
{showChevron && (
@@ -225,12 +272,33 @@ export function SpaceSelector({
>
<div className="flex flex-col gap-2">
<div className="flex flex-col">
{/* Default Project - no delete allowed */}
<DropdownMenuItem
onClick={() => handleSelect(DEFAULT_PROJECT_ID)}
onClick={handleSelectNovaSpaces}
className={cn(
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium",
value === DEFAULT_PROJECT_ID
isNovaSpaces
? "bg-[#293952]/40"
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
)}
>
<Globe className="size-4" />
<span className="flex-1">Nova Spaces</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033] my-1" />
<div className="px-3 py-1">
<span className="text-[10px] uppercase tracking-wider text-[#737373] font-medium">
My Spaces
</span>
</div>
<DropdownMenuItem
onClick={() => handleSelectSingleSpace(DEFAULT_PROJECT_ID)}
className={cn(
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium",
selectedProjects.length === 1 &&
selectedProjects[0] === DEFAULT_PROJECT_ID
? "bg-[#293952]/40"
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
)}
@@ -239,16 +307,21 @@ export function SpaceSelector({
<span className="flex-1">My Space</span>
</DropdownMenuItem>
{/* User Projects */}
{projects
.filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID)
.map((project: Project) => (
{novaProjects
.filter(
(p: ContainerTagListType) =>
p.containerTag !== DEFAULT_PROJECT_ID,
)
.map((project: ContainerTagListType) => (
<DropdownMenuItem
key={project.id}
onClick={() => handleSelect(project.containerTag)}
onClick={() =>
handleSelectSingleSpace(project.containerTag)
}
className={cn(
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium group",
value === project.containerTag
selectedProjects.length === 1 &&
selectedProjects[0] === project.containerTag
? "bg-[#293952]/40"
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
)}
@@ -256,7 +329,9 @@ export function SpaceSelector({
<span className="font-bold tracking-[-0.98px]">
{project.emoji || "📁"}
</span>
<span className="truncate flex-1">{project.name}</span>
<span className="truncate flex-1">
{project.name ?? project.containerTag}
</span>
{enableDelete && (
<button
type="button"
@@ -276,6 +351,20 @@ export function SpaceSelector({
))}
</div>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<button
type="button"
onClick={handleOpenSelectSpaces}
className="flex items-center justify-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium border border-[#161F2C] hover:bg-[#0D121A]/80 transition-colors"
style={{
background: "linear-gradient(180deg, #0D121A 0%, #000000 100%)",
}}
>
<Layers className="size-4" />
<span>Select Spaces</span>
</button>
{showNewSpace && (
<button
type="button"
@@ -299,10 +388,17 @@ export function SpaceSelector({
onClose={() => setShowCreateDialog(false)}
/>
{/* Delete Confirmation Dialog - matching /new design system */}
<SelectSpacesModal
isOpen={showSelectSpacesModal}
onClose={() => setShowSelectSpacesModal(false)}
selectedProjects={selectedProjects}
onApply={handleSelectSpacesApply}
projects={allProjects}
/>
<Dialog
open={deleteDialog.open}
onOpenChange={(open) => {
onOpenChange={(open: boolean) => {
if (!open) {
setDeleteDialog({
open: false,
@@ -330,21 +426,21 @@ export function SpaceSelector({
className="flex justify-between items-start gap-4"
>
<div className="pl-1 space-y-1 flex-1">
<p
<DialogTitle
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Delete space
</p>
<p className="text-[#737373] font-medium text-[16px] leading-[1.35]">
</DialogTitle>
<DialogDescription className="text-[#737373] font-medium text-[16px] leading-[1.35]">
What would you like to do with the documents and memories in{" "}
<span className="text-[#fafafa] font-medium">
"{deleteDialog.project?.name}"
</span>
?
</p>
</DialogDescription>
</div>
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
@@ -404,7 +500,7 @@ export function SpaceSelector({
>
<Select
value={deleteDialog.targetProjectId}
onValueChange={(val) =>
onValueChange={(val: string) =>
setDeleteDialog((prev) => ({
...prev,
targetProjectId: val,
@@ -433,22 +529,24 @@ export function SpaceSelector({
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
}}
>
{availableTargetProjects.map((p: Project) => (
<SelectItem
key={p.id}
value={p.id}
className="text-[#fafafa] hover:bg-[#1B1F24] cursor-pointer rounded-md"
>
<span className="flex items-center gap-2">
<span>{p.emoji || "📁"}</span>
<span>
{p.containerTag === DEFAULT_PROJECT_ID
? "My Space"
: p.name}
{availableTargetProjects.map(
(p: ContainerTagListType) => (
<SelectItem
key={p.id}
value={p.id}
className="text-[#fafafa] hover:bg-[#1B1F24] cursor-pointer rounded-md"
>
<span className="flex items-center gap-2">
<span>{p.emoji || "📁"}</span>
<span>
{p.containerTag === DEFAULT_PROJECT_ID
? "My Space"
: p.name}
</span>
</span>
</span>
</SelectItem>
))}
</SelectItem>
),
)}
</SelectContent>
</Select>
</motion.div>
+37
View File
@@ -0,0 +1,37 @@
"use client"
import { useQuery } from "@tanstack/react-query"
import { useMemo } from "react"
import { $fetch } from "@repo/lib/api"
import type { ContainerTagListType } from "@repo/lib/types"
export function useContainerTags() {
const { data: allProjects = [], isLoading } = useQuery({
queryKey: ["container-tags"],
queryFn: async () => {
const response = await $fetch("@get/container-tags/list")
if (response.error) {
throw new Error(response.error?.message || "Failed to load projects")
}
return (response.data || []) as ContainerTagListType[]
},
staleTime: 30 * 1000,
})
const novaProjects = useMemo(
() => allProjects.filter((p) => p.isNova),
[allProjects],
)
const novaContainerTags = useMemo(
() => novaProjects.map((p) => p.containerTag),
[novaProjects],
)
return {
allProjects,
novaProjects,
novaContainerTags,
isLoading,
}
}
+188 -198
View File
@@ -1,6 +1,10 @@
"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import {
useMutation,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query"
import { toast } from "sonner"
import { $fetch } from "@lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
@@ -31,12 +35,136 @@ interface UseDocumentMutationsOptions {
onClose?: () => void
}
interface OptimisticMemory {
id: string
content: string
url: string | null
title: string
description: string
containerTags: string[]
createdAt: string
updatedAt: string
status: string
type: string
metadata: Record<string, unknown>
memoryEntries: unknown[]
isOptimistic?: boolean
}
function addOptimisticMemoryToQueryData(
old: unknown,
memory: OptimisticMemory,
): unknown {
if (!old || typeof old !== "object") 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, index: number) => {
if (index !== 0) return page
const p = page as Record<string, unknown>
if (!p?.documents || !Array.isArray(p.documents)) return page
return {
...p,
documents: [memory, ...p.documents],
pagination: p.pagination
? {
...(p.pagination as Record<string, unknown>),
totalItems:
((p.pagination as Record<string, number>).totalItems ?? 0) +
1,
}
: p.pagination,
}
}),
}
}
if ("documents" in data && Array.isArray(data.documents)) {
return {
...data,
documents: [memory, ...data.documents],
totalCount: ((data.totalCount as number) ?? 0) + 1,
}
}
return old
}
function removeDocumentFromQueryData(
old: unknown,
documentId: string,
): unknown {
if (!old || typeof old !== "object") 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
return {
...p,
documents: (p.documents as DocumentWithId[]).filter(
(doc) => doc.id !== documentId && doc.customId !== documentId,
),
pagination: p.pagination
? {
...(p.pagination as Record<string, unknown>),
totalItems: Math.max(
0,
((p.pagination as Record<string, number>).totalItems ?? 0) -
1,
),
}
: p.pagination,
}
}),
}
}
if ("documents" in data && Array.isArray(data.documents)) {
return {
...data,
documents: (data.documents as DocumentWithId[]).filter(
(doc) => doc.id !== documentId && doc.customId !== documentId,
),
totalCount: Math.max(0, ((data.totalCount as number) ?? 0) - 1),
}
}
return old
}
async function cancelAndSnapshotQueries(
queryClient: QueryClient,
): Promise<[unknown, unknown][]> {
await queryClient.cancelQueries({ queryKey: ["documents-with-memories"] })
return queryClient.getQueriesData({ queryKey: ["documents-with-memories"] })
}
function restoreQueriesFromSnapshot(
queryClient: QueryClient,
previousQueries: [unknown, unknown][] | undefined,
): void {
if (!previousQueries) return
for (const [queryKey, data] of previousQueries) {
queryClient.setQueryData(queryKey as unknown[], data)
}
}
export function useDocumentMutations({
onClose,
}: UseDocumentMutationsOptions = {}) {
const queryClient = useQueryClient()
const { user } = useAuth()
const entityContext = `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`
const noteMutation = useMutation({
mutationFn: async ({
content,
@@ -47,12 +175,10 @@ export function useDocumentMutations({
}) => {
const response = await $fetch("@post/documents", {
body: {
content: content,
content,
containerTags: [project],
entityContext: `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`,
metadata: {
sm_source: "consumer",
},
entityContext,
metadata: { sm_source: "consumer" },
},
})
@@ -63,24 +189,18 @@ export function useDocumentMutations({
return response.data
},
onMutate: async ({ content, project }) => {
await queryClient.cancelQueries({
queryKey: ["documents-with-memories", project],
})
const previousQueries = await cancelAndSnapshotQueries(queryClient)
const now = new Date().toISOString()
const previousMemories = queryClient.getQueryData([
"documents-with-memories",
project,
])
const optimisticMemory = {
const optimisticMemory: OptimisticMemory = {
id: `temp-${crypto.randomUUID()}`,
content: content,
content,
url: null,
title: content.substring(0, 100),
description: "Processing content...",
containerTags: [project],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdAt: now,
updatedAt: now,
status: "queued",
type: "note",
metadata: {
@@ -91,42 +211,25 @@ export function useDocumentMutations({
isOptimistic: true,
}
queryClient.setQueryData(
["documents-with-memories", project],
(old: DocumentsQueryData | undefined) => {
const existingDocs = old?.documents ?? []
return {
...old,
documents: [optimisticMemory, ...existingDocs],
totalCount: (old?.totalCount ?? 0) + 1,
}
},
queryClient.setQueriesData(
{ queryKey: ["documents-with-memories"] },
(old) => addOptimisticMemoryToQueryData(old, optimisticMemory),
)
return { previousMemories }
return { previousQueries }
},
onError: (_error, variables, context) => {
if (context?.previousMemories) {
queryClient.setQueryData(
["documents-with-memories", variables.project],
context.previousMemories,
)
}
onError: (error, _variables, context) => {
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
toast.error("Failed to add note", {
description: _error instanceof Error ? _error.message : "Unknown error",
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
analytics.documentAdded({
type: "note",
project_id: variables.project,
})
analytics.documentAdded({ type: "note", project_id: variables.project })
toast.success("Note added successfully!", {
description: "Your note is being processed",
})
queryClient.invalidateQueries({
queryKey: ["documents-with-memories", variables.project],
})
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
onClose?.()
},
})
@@ -137,10 +240,8 @@ export function useDocumentMutations({
body: {
content: url,
containerTags: [project],
entityContext: `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`,
metadata: {
sm_source: "consumer",
},
entityContext,
metadata: { sm_source: "consumer" },
},
})
@@ -151,24 +252,18 @@ export function useDocumentMutations({
return response.data
},
onMutate: async ({ url, project }) => {
await queryClient.cancelQueries({
queryKey: ["documents-with-memories", project],
})
const previousQueries = await cancelAndSnapshotQueries(queryClient)
const now = new Date().toISOString()
const previousMemories = queryClient.getQueryData([
"documents-with-memories",
project,
])
const optimisticMemory = {
const optimisticMemory: OptimisticMemory = {
id: `temp-${crypto.randomUUID()}`,
content: "",
url: url,
url,
title: "Processing...",
description: "Extracting content...",
containerTags: [project],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdAt: now,
updatedAt: now,
status: "queued",
type: "link",
metadata: {
@@ -179,42 +274,25 @@ export function useDocumentMutations({
isOptimistic: true,
}
queryClient.setQueryData(
["documents-with-memories", project],
(old: DocumentsQueryData | undefined) => {
const existingDocs = old?.documents ?? []
return {
...old,
documents: [optimisticMemory, ...existingDocs],
totalCount: (old?.totalCount ?? 0) + 1,
}
},
queryClient.setQueriesData(
{ queryKey: ["documents-with-memories"] },
(old) => addOptimisticMemoryToQueryData(old, optimisticMemory),
)
return { previousMemories }
return { previousQueries }
},
onError: (_error, variables, context) => {
if (context?.previousMemories) {
queryClient.setQueryData(
["documents-with-memories", variables.project],
context.previousMemories,
)
}
onError: (error, _variables, context) => {
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
toast.error("Failed to add link", {
description: _error instanceof Error ? _error.message : "Unknown error",
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
analytics.documentAdded({
type: "link",
project_id: variables.project,
})
analytics.documentAdded({ type: "link", project_id: variables.project })
toast.success("Link added successfully!", {
description: "Your link is being processed",
})
queryClient.invalidateQueries({
queryKey: ["documents-with-memories", variables.project],
})
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
onClose?.()
},
})
@@ -234,16 +312,8 @@ export function useDocumentMutations({
const formData = new FormData()
formData.append("file", file)
formData.append("containerTags", JSON.stringify([project]))
formData.append(
"entityContext",
`This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`,
)
formData.append(
"metadata",
JSON.stringify({
sm_source: "consumer",
}),
)
formData.append("entityContext", entityContext)
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
@@ -276,24 +346,18 @@ export function useDocumentMutations({
return data
},
onMutate: async ({ file, title, description, project }) => {
await queryClient.cancelQueries({
queryKey: ["documents-with-memories", project],
})
const previousQueries = await cancelAndSnapshotQueries(queryClient)
const now = new Date().toISOString()
const previousMemories = queryClient.getQueryData([
"documents-with-memories",
project,
])
const optimisticMemory = {
const optimisticMemory: OptimisticMemory = {
id: `temp-file-${crypto.randomUUID()}`,
content: "",
url: null,
title: title || file.name,
description: description || `Uploading ${file.name}...`,
containerTags: [project],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdAt: now,
updatedAt: now,
status: "processing",
type: "file",
metadata: {
@@ -304,42 +368,25 @@ export function useDocumentMutations({
memoryEntries: [],
}
queryClient.setQueryData(
["documents-with-memories", project],
(old: DocumentsQueryData | undefined) => {
const existingDocs = old?.documents ?? []
return {
...old,
documents: [optimisticMemory, ...existingDocs],
totalCount: (old?.totalCount ?? 0) + 1,
}
},
queryClient.setQueriesData(
{ queryKey: ["documents-with-memories"] },
(old) => addOptimisticMemoryToQueryData(old, optimisticMemory),
)
return { previousMemories }
return { previousQueries }
},
onError: (_error, variables, context) => {
if (context?.previousMemories) {
queryClient.setQueryData(
["documents-with-memories", variables.project],
context.previousMemories,
)
}
onError: (error, _variables, context) => {
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
toast.error("Failed to upload file", {
description: _error instanceof Error ? _error.message : "Unknown error",
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
analytics.documentAdded({
type: "file",
project_id: variables.project,
})
analytics.documentAdded({ type: "file", project_id: variables.project })
toast.success("File uploaded successfully!", {
description: "Your file is being processed",
})
queryClient.invalidateQueries({
queryKey: ["documents-with-memories", variables.project],
})
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
onClose?.()
},
})
@@ -355,9 +402,7 @@ export function useDocumentMutations({
const response = await $fetch(`@patch/documents/${documentId}`, {
body: {
content,
metadata: {
sm_source: "consumer",
},
metadata: { sm_source: "consumer" },
},
})
@@ -370,9 +415,7 @@ export function useDocumentMutations({
onSuccess: (_data, variables) => {
analytics.documentEdited({ document_id: variables.documentId })
toast.success("Document saved successfully!")
queryClient.invalidateQueries({
queryKey: ["documents-with-memories"],
})
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
},
onError: (error) => {
toast.error("Failed to save document", {
@@ -394,78 +437,25 @@ export function useDocumentMutations({
return response.data
},
onMutate: async ({ documentId }) => {
await queryClient.cancelQueries({
queryKey: ["documents-with-memories"],
})
const previousQueries = queryClient.getQueriesData({
queryKey: ["documents-with-memories"],
})
const previousQueries = await cancelAndSnapshotQueries(queryClient)
queryClient.setQueriesData(
{ queryKey: ["documents-with-memories"] },
(old: QueryData | undefined) => {
if (!old) return old
if ("pages" in old) {
const infiniteData = old as InfiniteQueryData
return {
...infiniteData,
pages: infiniteData.pages.map((page) => {
if (!page?.documents) return page
return {
...page,
documents: page.documents.filter(
(doc) =>
doc.id !== documentId && doc.customId !== documentId,
),
pagination: page.pagination
? {
...page.pagination,
totalItems: Math.max(
0,
(page.pagination.totalItems ?? 0) - 1,
),
}
: page.pagination,
}
}),
}
}
if ("documents" in old) {
const queryData = old as DocumentsQueryData
return {
...queryData,
documents: queryData.documents.filter((doc: DocumentWithId) => {
return doc.id !== documentId && doc.customId !== documentId
}),
totalCount: Math.max(0, (queryData.totalCount ?? 0) - 1),
}
}
return old
},
(old) => removeDocumentFromQueryData(old, documentId),
)
return { previousQueries }
},
onError: (_error, _variables, context) => {
if (context?.previousQueries) {
context.previousQueries.forEach(([queryKey, data]) => {
queryClient.setQueryData(queryKey, data)
})
}
onError: (error, _variables, context) => {
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
toast.error("Failed to delete document", {
description: _error instanceof Error ? _error.message : "Unknown error",
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
analytics.documentDeleted({ document_id: variables.documentId })
toast.success("Document deleted successfully!")
queryClient.invalidateQueries({
queryKey: ["documents-with-memories"],
})
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
onClose?.()
},
})
+17 -9
View File
@@ -4,10 +4,12 @@ import { $fetch } from "@lib/api"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { useProject } from "@/stores"
import type { Project } from "@repo/lib/types"
export function useProjectMutations() {
const queryClient = useQueryClient()
const { selectedProject, setSelectedProject } = useProject()
const { selectedProjects, setSelectedProjects, setSelectedProject } =
useProject()
const createProjectMutation = useMutation({
mutationFn: async (input: string | { name: string; emoji?: string }) => {
@@ -28,9 +30,8 @@ export function useProjectMutations() {
toast.success("Project created successfully!")
queryClient.invalidateQueries({ queryKey: ["projects"] })
// Automatically switch to the newly created project
if (data?.containerTag) {
setSelectedProject(data.containerTag)
setSelectedProjects([data.containerTag])
}
},
onError: (error) => {
@@ -63,13 +64,20 @@ export function useProjectMutations() {
onSuccess: (_, variables) => {
toast.success("Project deleted successfully")
queryClient.invalidateQueries({ queryKey: ["projects"] })
queryClient.invalidateQueries({ queryKey: ["container-tags"] })
// If we deleted the selected project, switch to default
const deletedProject = queryClient
.getQueryData<any[]>(["projects"])
?.find((p) => p.id === variables.projectId)
if (deletedProject?.containerTag === selectedProject) {
setSelectedProject("sm_project_default")
const allProjects =
queryClient.getQueryData<Project[]>(["projects"]) || []
const deletedProject = allProjects.find(
(p) => p.id === variables.projectId,
)
if (
deletedProject?.containerTag &&
selectedProjects.includes(deletedProject.containerTag)
) {
setSelectedProjects(
selectedProjects.filter((tag) => tag !== deletedProject.containerTag),
)
}
},
onError: (error) => {
+1 -1
View File
@@ -29,4 +29,4 @@ export const viewParam = parseAsStringLiteral(viewLiterals).withDefault("list")
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(
[],
)
export const projectParam = parseAsString.withDefault("sm_project_default")
export const projectParam = parseAsArrayOf(parseAsString, ",").withDefault([])
+2
View File
@@ -19,6 +19,7 @@
"@ai-sdk/react": "^3.0.39",
"@ai-sdk/xai": "^3.0.23",
"@better-fetch/fetch": "^1.1.18",
"@cloudflare/ai-chat": "^0.0.7",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -63,6 +64,7 @@
"@tiptap/suggestion": "^3.15.3",
"@types/d3-force": "^3.0.10",
"@types/dompurify": "^3.2.0",
"agents": "^0.4.0",
"ai": "^6.0.35",
"autumn-js": "0.0.116",
"class-variance-authority": "^0.7.1",
+37 -5
View File
@@ -2,22 +2,54 @@
import { useQueryState } from "nuqs"
import { projectParam } from "@/lib/search-params"
import { useCallback } from "react"
import { useCallback, useMemo } from "react"
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
import { useContainerTags } from "@/hooks/use-container-tags"
export function useProject() {
const [selectedProject, _setSelectedProject] = useQueryState(
const [selectedProjects, _setSelectedProjects] = useQueryState(
"project",
projectParam,
)
const { novaContainerTags } = useContainerTags()
const isNovaSpaces = selectedProjects.length === 0
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 setSelectedProjects = useCallback(
(projects: string[]) => {
_setSelectedProjects(projects.length === 0 ? null : projects)
},
[_setSelectedProjects],
)
const setSelectedProject = useCallback(
(projectId: string) => {
_setSelectedProject(projectId)
_setSelectedProjects([projectId])
},
[_setSelectedProject],
[_setSelectedProjects],
)
return { selectedProject, setSelectedProject }
return {
selectedProjects,
selectedProject,
setSelectedProjects,
setSelectedProject,
isNovaSpaces,
effectiveContainerTags,
novaContainerTags,
}
}
export { usePersistentChat, usePersistentChatStore } from "./chat"
+4
View File
@@ -10,6 +10,7 @@ import {
DeleteProjectSchema,
DocumentsWithMemoriesQuerySchema,
DocumentsWithMemoriesResponseSchema,
ListContainerTagsResponseSchema,
ListMemoriesResponseSchema,
ListProjectsResponseSchema,
MemoryAddSchema,
@@ -179,6 +180,9 @@ export const apiSchema = createSchema({
"@get/projects": {
output: ListProjectsResponseSchema,
},
"@get/container-tags/list": {
output: ListContainerTagsResponseSchema,
},
"@post/projects": {
input: CreateProjectSchema,
output: ProjectSchema,
+5 -4
View File
@@ -1,7 +1,3 @@
/**
* Common TypeScript types shared across the application
*/
export interface Project {
id: string
name: string
@@ -11,3 +7,8 @@ export interface Project {
isExperimental?: boolean
emoji?: string
}
export interface ContainerTagListType extends Project {
isExperimental: boolean
isNova: boolean
}
+63 -1
View File
@@ -1123,6 +1123,20 @@ export const DocumentsWithMemoriesQuerySchema = z
description: "Query parameters for listing documents with memory entries",
})
export const DocumentFacetsQuerySchema = z
.object({
containerTags: z
.array(z.string())
.optional()
.openapi({
description: "Optional container tags to filter facets by",
example: ["sm_project_default"],
}),
})
.openapi({
description: "Query parameters for getting document facets",
})
export const MigrateMCPRequestSchema = z
.object({
userId: z.string().openapi({
@@ -1269,7 +1283,7 @@ export const CreateProjectSchema = z
export const ListProjectsResponseSchema = z
.object({
projects: z.array(ProjectSchema).openapi({
description: "List of projects",
description: "List of user-created projects with sm_project_* prefix",
}),
})
.openapi({
@@ -1400,3 +1414,51 @@ export const BulkDeleteMemoriesResponseSchema = z
.openapi({
description: "Response for bulk memory deletion",
})
export const ContainerTagListTypeSchema = z
.object({
id: z.string().openapi({
description: "Unique identifier of the container tag/space",
example: "space_abc123",
}),
name: z.string().openapi({
description: "Display name of the container tag",
example: "My Project",
}),
containerTag: z.string().openapi({
description: "The container tag identifier",
example: "sm_project_my_project",
}),
createdAt: z.string().openapi({
description: "Creation timestamp",
example: new Date().toISOString(),
format: "date-time",
}),
updatedAt: z.string().openapi({
description: "Last update timestamp",
example: new Date().toISOString(),
format: "date-time",
}),
isExperimental: z.boolean().openapi({
description: "Whether the space is experimental",
example: false,
}),
emoji: z.string().optional().openapi({
description: "Emoji icon for the container tag",
example: "📁",
}),
isNova: z.boolean().openapi({
description: "True if containerTag starts with 'sm_project_'",
example: true,
}),
})
.openapi({
description:
"Container tag with isNova flag indicating Nova vs developer project",
})
export const ListContainerTagsResponseSchema = z
.array(ContainerTagListTypeSchema)
.openapi({
description: "Flat array of all container tags with isNova flag",
})