From 15613c242196b5654c7c4759c1f4db7ef69096d4 Mon Sep 17 00:00:00 2001 From: Prasanna721 <106952318+Prasanna721@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:34:57 +0000 Subject: [PATCH] feat: change "All Spaces" to "Nova Spaces" with multi-select support (#731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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 --- apps/web/app/(app)/page.tsx | 8 +- apps/web/components/connect-ai-modal.tsx | 6 +- .../web/components/new/add-document/index.tsx | 110 +++-- .../new/documents-command-palette.tsx | 114 +++--- apps/web/components/new/graph-layout-view.tsx | 11 +- apps/web/components/new/header.tsx | 12 +- apps/web/components/new/mcp-modal/index.tsx | 6 +- apps/web/components/new/memories-grid.tsx | 18 +- .../components/new/select-spaces-modal.tsx | 226 ++++++++++ apps/web/components/new/space-selector.tsx | 248 +++++++---- apps/web/hooks/use-container-tags.ts | 37 ++ apps/web/hooks/use-document-mutations.ts | 386 +++++++++--------- apps/web/hooks/use-project-mutations.ts | 26 +- apps/web/lib/search-params.ts | 2 +- apps/web/package.json | 2 + apps/web/stores/index.ts | 42 +- packages/lib/api.ts | 4 + packages/lib/types.ts | 9 +- packages/validation/api.ts | 64 ++- 19 files changed, 925 insertions(+), 406 deletions(-) create mode 100644 apps/web/components/new/select-spaces-modal.tsx create mode 100644 apps/web/hooks/use-container-tags.ts diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 1c112809f..a85cec134 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -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() diff --git a/apps/web/components/connect-ai-modal.tsx b/apps/web/components/connect-ai-modal.tsx index d9353e6d2..718d1acb3 100644 --- a/apps/web/components/connect-ai-modal.tsx +++ b/apps/web/components/connect-ai-modal.tsx @@ -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, + ) => handleChange(e.target.value)} placeholder="https://mcp.supermemory.ai/your-user-id/sse" value={state.value} /> diff --git a/apps/web/components/new/add-document/index.tsx b/apps/web/components/new/add-document/index.tsx index e3ae9ab8f..56142b301 100644 --- a/apps/web/components/new/add-document/index.tsx +++ b/apps/web/components/new/add-document/index.tsx @@ -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({ {!isMobile && ( -
+
- + Credits - - {isLoadingUsage ? "…" : `${tokensToCredits(tokensUsed)} / ${tokensToCredits(tokensLimit)}`} + + {isLoadingUsage + ? "…" + : `${tokensToCredits(tokensUsed)} / ${tokensToCredits(tokensLimit)}`}
@@ -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", }} />
@@ -280,11 +296,24 @@ export function AddDocument({
- + Search Queries - - {isLoadingUsage ? "…" : `${formatUsageNumber(searchesUsed)} / ${formatUsageNumber(searchesLimit)}`} + + {isLoadingUsage + ? "…" + : `${formatUsageNumber(searchesUsed)} / ${formatUsageNumber(searchesLimit)}`}
@@ -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", }} />
@@ -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 && ( + setLocalSelectedProject(projects[0] ?? localSelectedProject) + } variant="insideOut" /> )} diff --git a/apps/web/components/new/documents-command-palette.tsx b/apps/web/components/new/documents-command-palette.tsx index bff1ebcf4..8dae7e580 100644 --- a/apps/web/components/new/documents-command-palette.tsx +++ b/apps/web/components/new/documents-command-palette.tsx @@ -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["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 | null>(null) const abortRef = useRef(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: , - action: () => { close(); onAddMemory() }, - }] + ? [ + { + kind: "action" as const, + id: "add-memory", + label: "Add Memory", + icon: , + action: () => { + close() + onAddMemory() + }, + }, + ] : []), ...(onOpenIntegrations - ? [{ - kind: "action" as const, - id: "integrations", - label: "Open Integrations", - icon: , - action: () => { close(); onOpenIntegrations() }, - }] + ? [ + { + kind: "action" as const, + id: "integrations", + label: "Open Integrations", + icon: , + 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") && ( -
-

No results found

-
- )} + {hasQuery && + !isSearching && + searchResults.length === 0 && + items.every((i) => i.kind === "action") && ( +
+

No results found

+
+ )}
diff --git a/apps/web/components/new/graph-layout-view.tsx b/apps/web/components/new/graph-layout-view.tsx index 9d460c038..180ce6424 100644 --- a/apps/web/components/new/graph-layout-view.tsx +++ b/apps/web/components/new/graph-layout-view.tsx @@ -17,13 +17,14 @@ interface GraphLayoutViewProps { } export const GraphLayoutView = memo(({ 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(null) - const containerTags = selectedProject ? [selectedProject] : undefined - const handleShare = useCallback(() => { setIsShareModalOpen(true) }, [setIsShareModalOpen]) @@ -37,7 +38,7 @@ export const GraphLayoutView = memo(({ isChatOpen }) => { {/* Full-width graph */}
{!isMobile && ( @@ -196,8 +194,8 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) { {isMobile ? ( <>
+ switchProject(projects[0] ?? selectedProject) + } variant="insideOut" /> + ) + }) + )} +
+ +
+

+ {localSelection.length === 0 + ? "No spaces selected (showing all)" + : `${localSelection.length} space${localSelection.length > 1 ? "s" : ""} selected`} +

+
+ + +
+
+
+ + + ) +} diff --git a/apps/web/components/new/space-selector.tsx b/apps/web/components/new/space-selector.tsx index 45b515127..46f6229f2 100644 --- a/apps/web/components/new/space-selector.tsx +++ b/apps/web/components/new/space-selector.tsx @@ -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,37 +88,63 @@ 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() + + const isNovaSpaces = selectedProjects.length === 0 - if (response.error) { - throw new Error(response.error?.message || "Failed to load projects") + const displayInfo = useMemo(() => { + if (isNovaSpaces) { + return { name: "Nova Spaces", emoji: null, isMultiple: false } + } + + 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 response.data?.projects || [] - }, - staleTime: 30 * 1000, - }) + return { + name: `${selectedProjects.length} spaces`, + emoji: null, + isMultiple: true, + } + }, [allProjects, selectedProjects, isNovaSpaces]) - 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 handleSelectNovaSpaces = () => { + analytics.spaceSwitched({ space_id: "nova_spaces" }) + onValueChange([]) // Empty array = "Nova Spaces" (all nova) + setIsOpen(false) + } - const selectedProjectName = selectedProject.name - const selectedProjectEmoji = selectedProject.emoji || "📁" + const handleSelectSingleSpace = (containerTag: string) => { + analytics.spaceSwitched({ space_id: containerTag }) + onValueChange([containerTag]) + setIsOpen(false) + } - const handleSelect = (containerTag: string) => { - if (containerTag !== value) { - analytics.spaceSwitched({ space_id: containerTag }) - } - onValueChange(containerTag) + 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 = () => { @@ -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({ + {showNewSpace && (