layout first

This commit is contained in:
Mahesh Sanikommmu 2025-09-03 13:39:39 -07:00
parent 85d4786f61
commit 6e93cac9b5
17 changed files with 319 additions and 852 deletions

View file

@ -15,6 +15,7 @@ export default defineConfig({
permissions: [
"contextMenus",
"storage",
"scripting",
"activeTab",
"webRequest",
"tabs",

View file

@ -7,6 +7,7 @@ import { ErrorTrackingProvider } from "@lib/error-tracking";
import { PostHogProvider } from "@lib/posthog";
import { QueryProvider } from "@lib/query-client";
import { AutumnProvider } from "autumn-js/react";
import { ThemeProvider } from "next-themes";
import { Suspense } from "react";
import { Toaster } from "sonner";
import { TourProvider } from "@/components/tour";
@ -36,33 +37,35 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html className="dark bg-sm-black" lang="en">
<html lang="en" suppressHydrationWarning>
<body
className={`${sans.variable} ${mono.variable} antialiased bg-[#0f1419]`}
className={`${sans.variable} ${mono.variable} antialiased`}
>
<AutumnProvider
backendUrl={
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
}
includeCredentials={true}
>
<QueryProvider>
<AuthProvider>
<ViewModeProvider>
<MobilePanelProvider>
<PostHogProvider>
<ErrorTrackingProvider>
<TourProvider>
<Suspense>{children}</Suspense>
<Toaster richColors theme="dark" />
</TourProvider>
</ErrorTrackingProvider>
</PostHogProvider>
</MobilePanelProvider>
</ViewModeProvider>
</AuthProvider>
</QueryProvider>
</AutumnProvider>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
<AutumnProvider
backendUrl={
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
}
includeCredentials={true}
>
<QueryProvider>
<AuthProvider>
<ViewModeProvider>
<MobilePanelProvider>
<PostHogProvider>
<ErrorTrackingProvider>
<TourProvider>
<Suspense>{children}</Suspense>
<Toaster richColors />
</TourProvider>
</ErrorTrackingProvider>
</PostHogProvider>
</MobilePanelProvider>
</ViewModeProvider>
</AuthProvider>
</QueryProvider>
</AutumnProvider>
</ThemeProvider>
</body>
</html>
);

View file

@ -1,57 +1,25 @@
"use client";
import { useIsMobile } from "@hooks/use-mobile";
import { useAuth } from "@lib/auth-context";
import { $fetch } from "@repo/lib/api";
import { MemoryGraph } from "@repo/ui/memory-graph";
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Logo, LogoFull } from "@ui/assets/Logo";
import { Button } from "@ui/components/button";
import { GlassMenuEffect } from "@ui/other/glass-effect";
import {
HelpCircle,
LayoutGrid,
List,
LoaderIcon,
MessageSquare,
Unplug,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { LoaderIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { z } from "zod";
import { ConnectAIModal } from "@/components/connect-ai-modal";
import { InstallPrompt } from "@/components/install-prompt";
import { AppHeader } from "@/components/header";
import { MemoryListView } from "@/components/memory-list-view";
import Menu from "@/components/menu";
import { ProjectSelector } from "@/components/project-selector";
import { ReferralUpgradeModal } from "@/components/referral-upgrade-modal";
import type { TourStep } from "@/components/tour";
import { TourAlertDialog, useTour } from "@/components/tour";
import { InstallPrompt } from "@/components/install-prompt";
import { AddMemoryView } from "@/components/views/add-memory";
import { ChatRewrite } from "@/components/views/chat";
import { TOUR_STEP_IDS, TOUR_STORAGE_KEY } from "@/lib/tour-constants";
import { useViewMode } from "@/lib/view-mode-context";
import { useChatOpen, useProject } from "@/stores";
import { useGraphHighlights } from "@/stores/highlights";
import { useProject } from "@/stores";
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>;
type DocumentWithMemories = DocumentsResponse["documents"][0];
const MemoryGraphPage = () => {
const { documentIds: allHighlightDocumentIds } = useGraphHighlights();
const isMobile = useIsMobile();
const { viewMode, setViewMode, isInitialized } = useViewMode();
const MemoryAppPage = () => {
const { selectedProject } = useProject();
const { setSteps, isTourCompleted } = useTour();
const { isOpen, setIsOpen } = useChatOpen();
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([]);
const [showAddMemoryView, setShowAddMemoryView] = useState(false);
const [showReferralModal, setShowReferralModal] = useState(false);
const [showConnectAIModal, setShowConnectAIModal] = useState(false);
const [isHelpHovered, setIsHelpHovered] = useState(false);
const [showAddMemoryModal, setShowAddMemoryModal] = useState(false);
// Fetch projects meta to detect experimental flag
const { data: projectsMeta = [] } = useQuery({
@ -64,170 +32,9 @@ const MemoryGraphPage = () => {
});
const isCurrentProjectExperimental = !!projectsMeta.find(
(p: any) => p.containerTag === selectedProject,
(p: { containerTag: string; isExperimental?: boolean }) => p.containerTag === selectedProject,
)?.isExperimental;
// Tour state
const [showTourDialog, setShowTourDialog] = useState(false);
// Define tour steps with useMemo to prevent recreation
const tourSteps: TourStep[] = useMemo(() => {
return [
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
Memories Overview
</h3>
<p className="text-gray-200">
This is your memory graph. Each node represents a memory, and
connections show relationships between them.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MEMORY_GRAPH,
position: "center",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
Add Memories
</h3>
<p className="text-gray-200">
Click here to add new memories to your knowledge base. You can add
text, links, or connect external sources.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_ADD_MEMORY,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
Connections
</h3>
<p className="text-gray-200">
Connect your external accounts like Google Drive, Notion, or
OneDrive to automatically sync and organize your content.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_CONNECTIONS,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">Projects</h3>
<p className="text-gray-200">
Organize your memories into projects. Switch between different
contexts easily.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_PROJECTS,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
MCP Servers
</h3>
<p className="text-gray-200">
Access Model Context Protocol servers to give AI tools access to
your memories securely.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_MCP,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">Billing</h3>
<p className="text-gray-200">
Manage your subscription and billing information.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_BILLING,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
View Toggle
</h3>
<p className="text-gray-200">
Switch between graph view and list view to see your memories in
different ways.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.VIEW_TOGGLE,
position: "left",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">Legend</h3>
<p className="text-gray-200">
Understand the different types of nodes and connections in your
memory graph.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.LEGEND,
position: "left",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
Chat Assistant
</h3>
<p className="text-gray-200">
Ask questions or add new memories using our AI-powered chat
interface.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.FLOATING_CHAT,
position: "left",
},
];
}, []);
// Check if tour has been completed before
useEffect(() => {
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true";
if (!hasCompletedTour && !isTourCompleted) {
const timer = setTimeout(() => {
setShowTourDialog(true);
setShowConnectAIModal(false);
}, 1000); // Show after 1 second
return () => clearTimeout(timer);
}
}, [isTourCompleted]);
// Set up tour steps
useEffect(() => {
setSteps(tourSteps);
}, [setSteps, tourSteps]);
// Save tour completion to localStorage
useEffect(() => {
if (isTourCompleted) {
localStorage.setItem(TOUR_STORAGE_KEY, "true");
}
}, [isTourCompleted]);
// Progressive loading via useInfiniteQuery
const IS_DEV = process.env.NODE_ENV === "development";
const PAGE_SIZE = IS_DEV ? 100 : 100;
@ -308,88 +115,23 @@ const MemoryGraphPage = () => {
setInjectedDocs([]);
}, [selectedProject]);
// Surgical fetch of missing highlighted documents (customId-based IDs from search)
useEffect(() => {
if (!isOpen) return;
if (!allHighlightDocumentIds || allHighlightDocumentIds.length === 0)
return;
const present = new Set<string>();
for (const d of [...baseDocuments, ...injectedDocs]) {
if (d.id) present.add(d.id);
if ((d as any).customId) present.add((d as any).customId as string);
}
const missing = allHighlightDocumentIds.filter(
(id: string) => !present.has(id),
);
if (missing.length === 0) return;
let cancelled = false;
const run = async () => {
try {
const resp = await $fetch("@post/memories/documents/by-ids", {
body: {
ids: missing,
by: "customId",
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
});
if (cancelled || (resp as any)?.error) return;
const extraDocs = (resp as any)?.data?.documents as
| DocumentWithMemories[]
| undefined;
if (!extraDocs || extraDocs.length === 0) return;
setInjectedDocs((prev) => {
const seen = new Set<string>([
...prev.map((d) => d.id),
...baseDocuments.map((d) => d.id),
]);
const merged = [...prev];
for (const doc of extraDocs) {
if (!seen.has(doc.id)) {
merged.push(doc);
seen.add(doc.id);
}
}
return merged;
});
} catch {}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'c' && !['INPUT', 'TEXTAREA'].includes((event.target as Element)?.tagName)) {
event.preventDefault();
setShowAddMemoryModal(true);
}
};
void run();
return () => {
cancelled = true;
};
}, [
isOpen,
allHighlightDocumentIds.join("|"),
baseDocuments,
injectedDocs,
selectedProject,
$fetch,
]);
// Handle view mode change
const handleViewModeChange = useCallback(
(mode: "graph" | "list") => {
setViewMode(mode);
},
[setViewMode],
);
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
useEffect(() => {
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true";
if (hasCompletedTour && allDocuments.length === 0 && !showTourDialog) {
setShowConnectAIModal(true);
} else if (showTourDialog) {
setShowConnectAIModal(false);
}
}, [allDocuments.length, showTourDialog]);
// Prevent body scrolling
useEffect(() => {
document.body.style.overflow = "hidden";
document.body.style.height = "100vh";
document.documentElement.style.overflow = "hidden";
document.documentElement.style.height = "100vh";
document.body.style.overflow = "auto";
document.body.style.height = "auto";
document.documentElement.style.overflow = "auto";
document.documentElement.style.height = "auto";
return () => {
document.body.style.overflow = "";
@ -400,380 +142,49 @@ const MemoryGraphPage = () => {
}, []);
return (
<div className="relative h-screen bg-[#0f1419] overflow-hidden touch-none">
{/* Main content area */}
<motion.div
animate={{
marginRight: isOpen && !isMobile ? 600 : 0,
}}
className="h-full relative"
transition={{
duration: 0.2,
ease: [0.4, 0, 0.2, 1], // Material Design easing - snappy but smooth
}}
>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="absolute md:top-4 md:right-4 md:bottom-auto md:left-auto bottom-8 left-6 z-20 rounded-xl overflow-hidden"
id={TOUR_STEP_IDS.VIEW_TOGGLE}
initial={{ opacity: 0, y: -20 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
<GlassMenuEffect rounded="rounded-xl" />
<div className="relative z-10 p-2 flex gap-1">
<motion.button
animate={{
color: viewMode === "graph" ? "#93c5fd" : "#cbd5e1",
}}
className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors"
onClick={() => handleViewModeChange("graph")}
transition={{ duration: 0.2 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{viewMode === "graph" && (
<motion.div
className="absolute inset-0 bg-blue-500/20 rounded-md"
layoutId="activeBackground"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<span className="relative z-10 flex items-center gap-2">
<LayoutGrid className="w-4 h-4" />
<span className="hidden md:inline">Graph</span>
</span>
</motion.button>
<div>
<AppHeader />
<MemoryListView
documents={allDocuments}
error={error}
hasMore={hasMore}
isLoading={isPending}
isLoadingMore={isLoadingMore}
totalLoaded={totalLoaded}
loadMoreDocuments={loadMoreDocuments}
isCurrentProjectExperimental={isCurrentProjectExperimental}
/>
<motion.button
animate={{
color: viewMode === "list" ? "#93c5fd" : "#cbd5e1",
}}
className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors"
onClick={() => handleViewModeChange("list")}
transition={{ duration: 0.2 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{viewMode === "list" && (
<motion.div
className="absolute inset-0 bg-blue-500/20 rounded-md"
layoutId="activeBackground"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<span className="relative z-10 flex items-center gap-2">
<List className="w-4 h-4" />
<span className="hidden md:inline">List</span>
</span>
</motion.button>
</div>
</motion.div>
{/* Animated content switching */}
<AnimatePresence mode="wait">
{viewMode === "graph" ? (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="absolute inset-0"
exit={{ opacity: 0, scale: 0.95 }}
id={TOUR_STEP_IDS.MEMORY_GRAPH}
initial={{ opacity: 0, scale: 0.95 }}
key="graph"
transition={{
type: "spring",
stiffness: 500,
damping: 30,
}}
>
<MemoryGraph
autoLoadOnViewport={false}
documents={allDocuments}
error={error}
hasMore={hasMore}
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isOpen}
isExperimental={isCurrentProjectExperimental}
isLoading={isPending}
isLoadingMore={isLoadingMore}
legendId={TOUR_STEP_IDS.LEGEND}
loadMoreDocuments={loadMoreDocuments}
occludedRightPx={isOpen && !isMobile ? 600 : 0}
showSpacesSelector={false}
totalLoaded={totalLoaded}
variant="consumer"
>
<div className="absolute inset-0 flex items-center justify-center">
<ConnectAIModal
onOpenChange={setShowConnectAIModal}
open={showConnectAIModal}
>
<div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6">
<div className="relative z-10 text-slate-200 text-center">
<p className="text-lg font-medium mb-4">
Get Started with supermemory
</p>
<div className="flex flex-col gap-3">
<p className="text-sm text-blue-400 hover:text-blue-300 transition-colors">
Click here to set up your AI connection
</p>
<p className="text-xs text-white/60">or</p>
<button
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
onClick={(e) => {
e.stopPropagation();
setShowAddMemoryView(true);
setShowConnectAIModal(false);
}}
type="button"
>
Add your first memory
</button>
</div>
</div>
</div>
</ConnectAIModal>
</div>
</MemoryGraph>
</motion.div>
) : (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="absolute inset-0 md:ml-18"
exit={{ opacity: 0, scale: 0.95 }}
id={TOUR_STEP_IDS.MEMORY_LIST}
initial={{ opacity: 0, scale: 0.95 }}
key="list"
transition={{
type: "spring",
stiffness: 500,
damping: 30,
}}
>
<MemoryListView
documents={allDocuments}
error={error}
hasMore={hasMore}
isLoading={isPending}
isLoadingMore={isLoadingMore}
loadMoreDocuments={loadMoreDocuments}
totalLoaded={totalLoaded}
>
<div className="absolute inset-0 flex items-center justify-center">
<ConnectAIModal
onOpenChange={setShowConnectAIModal}
open={showConnectAIModal}
>
<div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6">
<div className="relative z-10 text-slate-200 text-center">
<p className="text-lg font-medium mb-4">
Get Started with supermemory
</p>
<div className="flex flex-col gap-3">
<p className="text-sm text-blue-400 hover:text-blue-300 transition-colors">
Click here to set up your AI connection
</p>
<p className="text-xs text-white/60">or</p>
<button
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
onClick={(e) => {
e.stopPropagation();
setShowAddMemoryView(true);
setShowConnectAIModal(false);
}}
type="button"
>
Add your first memory
</button>
</div>
</div>
</div>
</ConnectAIModal>
</div>
</MemoryListView>
</motion.div>
)}
</AnimatePresence>
{/* Top Bar */}
<div className="absolute top-2 left-0 right-0 z-10 p-4 flex items-center justify-between">
<div className="flex items-center gap-3 justify-between w-full md:w-fit md:justify-start">
<Link
className="pointer-events-auto"
href="https://supermemory.ai"
rel="noopener noreferrer"
target="_blank"
>
<LogoFull
className="h-8 hidden md:block"
id={TOUR_STEP_IDS.LOGO}
/>
<Logo className="h-8 md:hidden" id={TOUR_STEP_IDS.LOGO} />
</Link>
<div className="hidden sm:block">
<ProjectSelector />
</div>
<ConnectAIModal>
<Button
className="bg-white/5 hover:bg-white/10 border-white/20 text-white hover:text-white px-2 sm:px-3"
size="sm"
variant="outline"
>
<Unplug className="h-4 w-4" />
<span className="hidden sm:inline ml-2">
Connect to your AI
</span>
<span className="sm:hidden ml-1">Connect AI</span>
</Button>
</ConnectAIModal>
</div>
<div>
<Menu />
</div>
</div>
{/* Floating Open Chat Button */}
{!isOpen && !isMobile && (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="fixed bottom-6 right-6 z-50"
initial={{ opacity: 0, scale: 0.8 }}
transition={{
type: "spring",
stiffness: 300,
damping: 25,
}}
>
<Button
className="px-4 bg-white hover:bg-white/80 text-[#001A39] shadow-lg hover:shadow-xl transition-all duration-200 rounded-full flex items-center gap-2 cursor-pointer"
onClick={() => setIsOpen(true)}
size="lg"
>
<MessageSquare className="h-5 w-5" />
<span className="font-medium">Open Chat</span>
</Button>
</motion.div>
)}
<button
className="fixed bottom-6 left-6 z-50 flex items-center overflow-hidden rounded-full shadow-2xl bg-transparent border-none cursor-pointer"
onMouseEnter={() => setIsHelpHovered(true)}
onMouseLeave={() => setIsHelpHovered(false)}
type="button"
>
<div className="absolute inset-0 rounded-full">
<GlassMenuEffect rounded="rounded-full" />
</div>
<div className="relative z-10 p-3 text-white">
<HelpCircle className="h-5 w-5" />
</div>
<div
className={`relative z-10 flex items-center text-white transition-all duration-300 overflow-hidden ${
isHelpHovered
? "opacity-100 max-w-32 pr-4 pl-0 py-3"
: "opacity-0 max-w-0 px-0 py-3"
}`}
>
<a
className="flex items-center gap-2 text-sm font-medium hover:text-white/80 transition-colors whitespace-nowrap"
href="mailto:dhravya@supermemory.com"
>
<span>Need Help?</span>
</a>
</div>
</button>
</motion.div>
{/* Chat panel - positioned absolutely */}
<motion.div
className="fixed top-0 right-0 h-full z-50 md:z-auto"
id={TOUR_STEP_IDS.FLOATING_CHAT}
style={{
width: isOpen ? (isMobile ? "100vw" : "600px") : 0,
pointerEvents: isOpen ? "auto" : "none",
}}
>
<motion.div
animate={{ x: isOpen ? 0 : isMobile ? "100%" : 600 }}
className="absolute inset-0"
exit={{ x: isMobile ? "100%" : 600 }}
initial={{ x: isMobile ? "100%" : 600 }}
key="chat"
transition={{
type: "spring",
stiffness: 500,
damping: 40,
}}
>
<ChatRewrite />
</motion.div>
</motion.div>
{showAddMemoryView && (
{showAddMemoryModal && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
onClose={() => setShowAddMemoryModal(false)}
/>
)}
{/* Tour Alert Dialog */}
<TourAlertDialog onOpenChange={setShowTourDialog} open={showTourDialog} />
{/* Referral/Upgrade Modal */}
<ReferralUpgradeModal
isOpen={showReferralModal}
onClose={() => setShowReferralModal(false)}
/>
</div>
);
};
// Wrapper component to handle auth and waitlist checks
// Wrapper component to handle auth
export default function Page() {
const router = useRouter();
const { user } = useAuth();
useEffect(() => {
// save the token for chrome extension
const url = new URL(window.location.href);
const rawToken = url.searchParams.get("token");
if (rawToken) {
const encodedToken = encodeURIComponent(rawToken);
window.postMessage({ token: encodedToken }, "*");
url.searchParams.delete("token");
window.history.replaceState({}, "", url.toString());
}
}, []);
// Show loading state while checking authentication and waitlist status
// Show loading state while checking authentication
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#0f1419]">
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<LoaderIcon className="w-8 h-8 text-orange-500 animate-spin" />
<p className="text-white/60">Loading...</p>
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
);
}
// If we have a user and they have access, show the main component
return (
<>
<MemoryGraphPage />
<MemoryAppPage />
<InstallPrompt />
</>
);

View file

@ -4,7 +4,6 @@ import { useIsMobile } from "@hooks/use-mobile";
import { cn } from "@lib/utils";
import { Badge } from "@repo/ui/components/badge";
import { Card, CardContent, CardHeader } from "@repo/ui/components/card";
import { colors } from "@repo/ui/memory-graph/constants";
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
@ -20,6 +19,8 @@ import { analytics } from "@/lib/analytics";
import { MemoryDetail } from "./memories/memory-detail";
import { getDocumentIcon } from "@/lib/document-icon";
import { formatDate, getSourceUrl } from "./memories";
import { ChatInline } from "./chat-inline";
import { GraphPreview } from "./graph-preview";
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>;
type DocumentWithMemories = DocumentsResponse["documents"][0];
@ -33,33 +34,9 @@ interface MemoryListViewProps {
totalLoaded: number;
hasMore: boolean;
loadMoreDocuments: () => Promise<void>;
isCurrentProjectExperimental?: boolean;
}
const GreetingMessage = memo(() => {
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
};
return (
<div className="flex items-center gap-3 mb-3 px-4 md:mb-6 md:mt-3">
<div>
<h1
className="text-lg md:text-xl font-semibold"
style={{ color: colors.text.primary }}
>
{getGreeting()}!
</h1>
<p className="text-xs md:text-sm" style={{ color: colors.text.muted }}>
Welcome back to your memory collection
</p>
</div>
</div>
);
});
const DocumentCard = memo(
({
document,
@ -75,14 +52,11 @@ const DocumentCard = memo(
return (
<Card
className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden border-0 gap-2 md:w-full"
className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden bg-card gap-2 md:w-full"
onClick={() => {
analytics.documentCardClicked();
onOpenDetails(document);
}}
style={{
backgroundColor: colors.document.primary,
}}
>
<CardHeader className="relative z-10 px-0">
<div className="flex items-center justify-between gap-2">
@ -99,16 +73,12 @@ const DocumentCard = memo(
</div>
{document.url && (
<button
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded"
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded bg-muted/50 text-muted-foreground hover:bg-muted/80"
onClick={(e) => {
e.stopPropagation();
const sourceUrl = getSourceUrl(document);
window.open(sourceUrl ?? undefined, "_blank");
}}
style={{
backgroundColor: "rgba(255, 255, 255, 0.05)",
color: colors.text.secondary,
}}
type="button"
>
<ExternalLink className="w-3 h-3" />
@ -122,8 +92,7 @@ const DocumentCard = memo(
<CardContent className="relative z-10 px-0">
{document.content && (
<p
className="text-xs line-clamp-2 mb-3"
style={{ color: colors.text.muted }}
className="text-xs line-clamp-2 mb-3 text-muted-foreground"
>
{document.content}
</p>
@ -131,10 +100,7 @@ const DocumentCard = memo(
<div className="flex items-center gap-2 flex-wrap">
{activeMemories.length > 0 && (
<Badge
className="text-xs text-accent-foreground"
style={{
backgroundColor: colors.memory.secondary,
}}
className="text-xs"
variant="secondary"
>
<Brain className="w-3 h-3 mr-1" />
@ -144,11 +110,7 @@ const DocumentCard = memo(
)}
{forgottenMemories.length > 0 && (
<Badge
className="text-xs"
style={{
borderColor: "rgba(255, 255, 255, 0.2)",
color: colors.text.muted,
}}
className="text-xs text-muted-foreground border-muted"
variant="outline"
>
{forgottenMemories.length} forgotten
@ -169,13 +131,14 @@ export const MemoryListView = ({
error,
hasMore,
loadMoreDocuments,
isCurrentProjectExperimental = false,
}: MemoryListViewProps) => {
const [selectedSpace, _] = useState<string>("all");
const [selectedDocument, setSelectedDocument] =
useState<DocumentWithMemories | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
const gap = 14;
@ -232,6 +195,7 @@ export const MemoryListView = ({
estimateSize: () => 200,
});
useEffect(() => {
const [lastItem] = [...virtualizer.getVirtualItems()].reverse();
@ -254,27 +218,24 @@ export const MemoryListView = ({
return (
<>
<div
className="h-full overflow-hidden relative pb-20"
style={{ backgroundColor: colors.background.primary }}
className="min-h-screen flex flex-col"
ref={containerRef}
>
{error ? (
<div className="h-full flex items-center justify-center p-4">
<div className="flex items-center justify-center p-4 min-h-screen">
<div className="rounded-xl overflow-hidden">
<div
className="relative z-10 px-6 py-4"
style={{ color: colors.text.primary }}
className="relative z-10 px-6 py-4 text-foreground"
>
Error loading documents: {error.message}
</div>
</div>
</div>
) : isLoading ? (
<div className="h-full flex items-center justify-center p-4">
<div className="flex items-center justify-center p-4 min-h-screen">
<div className="rounded-xl overflow-hidden">
<div
className="relative z-10 px-6 py-4"
style={{ color: colors.text.primary }}
className="relative z-10 px-6 py-4 text-foreground"
>
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 animate-spin text-blue-400" />
@ -284,67 +245,157 @@ export const MemoryListView = ({
</div>
</div>
) : filteredDocuments.length === 0 && !isLoading ? (
<div className="h-full flex items-center justify-center p-4">
{children}
<div className="flex flex-col min-h-screen">
<div className="p-6 pb-8">
{isMobile ? (
<>
<div className="mb-6">
<ChatInline />
</div>
<div className="mb-6 hidden sm:block">
<GraphPreview
documents={documents}
error={error}
hasMore={hasMore}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
totalLoaded={documents.length}
loadMoreDocuments={loadMoreDocuments}
isCurrentProjectExperimental={
isCurrentProjectExperimental
}
/>
</div>
</>
) : (
<div className="mb-6 flex gap-6">
<div className="w-2/3">
<ChatInline />
</div>
<div className="w-1/3">
<GraphPreview
documents={documents}
error={error}
hasMore={hasMore}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
totalLoaded={documents.length}
loadMoreDocuments={loadMoreDocuments}
isCurrentProjectExperimental={
isCurrentProjectExperimental
}
/>
</div>
</div>
)}
</div>
<div className="flex-1 flex items-center justify-center p-4">
{children}
</div>
</div>
) : (
<div
ref={parentRef}
className="h-full overflow-auto mt-20 custom-scrollbar"
>
<GreetingMessage />
<div
className="w-full relative"
style={{
height: `${virtualizer.getTotalSize() + virtualItems.length * gap}px`,
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const rowItems = virtualItems[virtualRow.index];
if (!rowItems) return null;
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
transform: `translateY(${virtualRow.start + virtualRow.index * gap}px)`,
}}
>
<div
className="grid justify-start"
style={{
gridTemplateColumns: `repeat(${columns}, ${columnWidth}px)`,
gap: `${gap}px`,
}}
>
{rowItems.map((document, columnIndex) => (
<DocumentCard
key={`${document.id}-${virtualRow.index}-${columnIndex}`}
document={document}
onOpenDetails={handleOpenDetails}
/>
))}
</div>
<>
<div className="p-6 pb-8 min-h-[48vh]">
{isMobile ? (
<>
<div className="mb-6">
<ChatInline />
</div>
);
})}
<div className="mb-6 hidden sm:block">
<GraphPreview
documents={documents}
error={error}
hasMore={hasMore}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
totalLoaded={documents.length}
loadMoreDocuments={loadMoreDocuments}
isCurrentProjectExperimental={
isCurrentProjectExperimental
}
/>
</div>
</>
) : (
<div className="flex gap-6 h-[48vh]">
<div className="w-2/3">
<ChatInline />
</div>
<div className="w-1/3">
<GraphPreview
documents={documents}
error={error}
hasMore={hasMore}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
totalLoaded={documents.length}
loadMoreDocuments={loadMoreDocuments}
isCurrentProjectExperimental={
isCurrentProjectExperimental
}
/>
</div>
</div>
)}
</div>
{isLoadingMore && (
<div className="py-8 flex items-center justify-center">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 animate-spin text-blue-400" />
<span style={{ color: colors.text.primary }}>
Loading more memories...
</span>
</div>
<div className="px-10 py-2">
<p className="font-medium text-lg">All Memories</p>
</div>
<div ref={parentRef} className="flex-1 overflow-auto pt-2">
<div
className="w-full relative"
style={{
height: `${virtualizer.getTotalSize() + virtualItems.length * gap}px`,
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const rowItems = virtualItems[virtualRow.index];
if (!rowItems) return null;
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
transform: `translateY(${virtualRow.start + virtualRow.index * gap}px)`,
}}
>
<div
className="grid justify-start px-4"
style={{
gridTemplateColumns: `repeat(${columns}, ${columnWidth}px)`,
gap: `${gap}px`,
}}
>
{rowItems.map((document, columnIndex) => (
<DocumentCard
key={`${document.id}-${virtualRow.index}-${columnIndex}`}
document={document}
onOpenDetails={handleOpenDetails}
/>
))}
</div>
</div>
);
})}
</div>
)}
</div>
{isLoadingMore && (
<div className="py-8 flex items-center justify-center">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 animate-spin text-blue-400" />
<span className="text-foreground">
Loading more memories...
</span>
</div>
</div>
)}
</div>
</>
)}
</div>

View file

@ -12,6 +12,7 @@ import {
ChevronRight,
Copy,
RotateCcw,
UserIcon,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
@ -327,22 +328,24 @@ export function ChatMessages() {
} = useStickyAutoScroll([messages, status]);
return (
<>
<div className="relative grow">
<div className="h-full mx-auto flex flex-col max-w-3xl">
<div className="flex-1 relative">
<div
className="flex flex-col gap-2 absolute inset-0 overflow-y-auto px-4 pt-4 pb-7 scroll-pb-7"
onScroll={onScroll}
ref={scrollContainerRef}
>
{messages.map((message) => (
<div
className={cn(
"flex flex-col",
message.role === "user" ? "items-end" : "items-start",
<div className={cn("flex", message.role === "user" ? "items-center gap-2" : "flex-col")} key={message.id}>
{message.role === "user" && (
<UserIcon className="size-4 text-muted-foreground" />
)}
key={message.id}
>
<div className="flex flex-col gap-2 max-w-4/5 bg-white/10 py-3 px-4 rounded-lg">
<div
className={cn(
"flex flex-col gap-2 max-w-4/5 rounded-full",
message.role === "user" ? "bg-white/10 px-3 py-1.5" : "",
)}
>
{message.parts
.filter((part) =>
["text", "tool-searchMemories", "tool-addMemory"].includes(
@ -515,7 +518,7 @@ export function ChatMessages() {
</div>
<form
className="flex gap-2 px-4 pb-4 pt-1 relative"
className="flex gap-2 px-4 pb-4 pt-1 relative flex-shrink-0"
onSubmit={(e) => {
e.preventDefault();
if (status === "submitted") return;
@ -531,7 +534,6 @@ export function ChatMessages() {
}
}}
>
<div className="absolute top-0 left-0 -mt-7 w-full h-7 bg-gradient-to-t from-background to-transparent" />
<Input
className="w-full"
disabled={status === "submitted"}
@ -549,6 +551,6 @@ export function ChatMessages() {
)}
</Button>
</form>
</>
</div>
);
}

View file

@ -1,5 +1,6 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants";
interface ProjectState {
selectedProject: string;
@ -9,7 +10,7 @@ interface ProjectState {
export const useProjectStore = create<ProjectState>()(
persist(
(set) => ({
selectedProject: "sm_project_default",
selectedProject: DEFAULT_PROJECT_ID,
setSelectedProject: (projectId) => set({ selectedProject: projectId }),
}),
{
@ -51,7 +52,14 @@ export function useProject() {
const setSelectedProject = useProjectStore(
(state) => state.setSelectedProject,
);
return { selectedProject, setSelectedProject };
const finalProject = selectedProject || DEFAULT_PROJECT_ID;
return {
selectedProject: finalProject,
setSelectedProject,
resetToDefault: () => setSelectedProject(DEFAULT_PROJECT_ID)
};
}
export function useMemoryGraphPosition() {

View file

@ -3,8 +3,8 @@
"assist": {
"actions": {
"source": {
"organizeImports": "on",
"useSortedAttributes": "on",
"organizeImports": "off",
"useSortedAttributes": "off",
"useSortedKeys": "off"
}
},
@ -95,6 +95,12 @@
}
}
}
},
{
"includes": ["**/*.css"],
"linter": {
"enabled": false
}
}
],
"vcs": {

View file

@ -29,6 +29,7 @@
"llm-bridge": "1.0.8",
"nanoid": "^5.1.5",
"neverthrow": "^8.2.0",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"pino": "^9.7.0",
"postgres": "^3.4.7",

View file

@ -43,6 +43,7 @@
"llm-bridge": "1.0.8",
"nanoid": "^5.1.5",
"neverthrow": "^8.2.0",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"pino": "^9.7.0",
"postgres": "^3.4.7",

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,5 @@
{
"root": false,
"extends": "//",
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json"
}

View file

@ -5,7 +5,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl py-6 shadow-sm",
className,
)}
data-slot="card"

View file

@ -10,6 +10,7 @@
--color-background: var(--color-sm-shark-alt);
--color-background: var(--color-sm-shark-alt);
--color-foreground: var(--foreground);
--color-dodger-blue: var(--color-sm-dodger-blue);
--font-sans:
"Space Grotesk", "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
@ -84,7 +85,7 @@
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--background: oklch(0.98 0.002 286);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
@ -151,15 +152,6 @@
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom Scrollbar Styles */
.custom-scrollbar {
scrollbar-width: thin;

View file

@ -67,7 +67,7 @@ export const GRAPH_SETTINGS = {
initialPanY: 0,
},
consumer: {
initialZoom: 0.5, // Changed from 0.1 to 0.5 for better initial visibility
initialZoom: 0.1, // Changed from 0.1 to 0.5 for better initial visibility
initialPanX: 400, // Pan towards center to compensate for larger layout
initialPanY: 300, // Pan towards center to compensate for larger layout
},

View file

@ -27,6 +27,7 @@ export const MemoryGraph = ({
showSpacesSelector = true,
variant = "console",
legendId,
showLegend = true,
highlightDocumentIds = [],
highlightsVisible = true,
occludedRightPx = 0,
@ -199,32 +200,47 @@ export const MemoryGraph = ({
const handleCenter = useCallback(() => {
if (nodes.length > 0) {
// Calculate center of all nodes
let sumX = 0
let sumY = 0
let count = 0
let sumX = 0;
let sumY = 0;
let count = 0;
nodes.forEach((node) => {
sumX += node.x
sumY += node.y
count++
})
sumX += node.x;
sumY += node.y;
count++;
});
if (count > 0) {
const centerX = sumX / count
const centerY = sumY / count
centerViewportOn(centerX, centerY, containerSize.width, containerSize.height)
const centerX = sumX / count;
const centerY = sumY / count;
centerViewportOn(
centerX,
centerY,
containerSize.width,
containerSize.height,
);
}
}
}, [nodes, centerViewportOn, containerSize.width, containerSize.height])
}, [nodes, centerViewportOn, containerSize.width, containerSize.height]);
const handleAutoFit = useCallback(() => {
if (nodes.length > 0 && containerSize.width > 0 && containerSize.height > 0) {
if (
nodes.length > 0 &&
containerSize.width > 0 &&
containerSize.height > 0
) {
autoFitToViewport(nodes, containerSize.width, containerSize.height, {
occludedRightPx,
animate: true,
})
});
}
}, [nodes, containerSize.width, containerSize.height, occludedRightPx, autoFitToViewport])
}, [
nodes,
containerSize.width,
containerSize.height,
occludedRightPx,
autoFitToViewport,
]);
// Get selected node data
const selectedNodeData = useMemo(() => {
@ -354,14 +370,16 @@ export const MemoryGraph = ({
/>
{/* Legend */}
<Legend
edges={edges}
id={legendId}
isLoading={isLoading}
nodes={nodes}
variant={variant}
isExperimental={isExperimental}
/>
{showLegend && (
<Legend
edges={edges}
id={legendId}
isLoading={isLoading}
nodes={nodes}
variant={variant}
isExperimental={isExperimental}
/>
)}
{/* Node detail panel */}
<AnimatePresence>
@ -421,8 +439,12 @@ export const MemoryGraph = ({
{containerSize.width > 0 && (
<NavigationControls
onCenter={handleCenter}
onZoomIn={() => zoomIn(containerSize.width / 2, containerSize.height / 2)}
onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)}
onZoomIn={() =>
zoomIn(containerSize.width / 2, containerSize.height / 2)
}
onZoomOut={() =>
zoomOut(containerSize.width / 2, containerSize.height / 2)
}
onAutoFit={handleAutoFit}
nodes={nodes}
className="absolute bottom-4 left-4"

View file

@ -34,39 +34,6 @@ export const SpacesDropdown = memo<SpacesDropdownProps>(
return (
<div className="relative" ref={dropdownRef}>
<button
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-xl border-2 border-solid border-transparent",
"[background-image:linear-gradient(#1a1f29,_#1a1f29),_linear-gradient(150.262deg,_#A4E8F5_0%,_#267FFA_26%,_#464646_49%,_#747474_70%,_#A4E8F5_100%)]",
"[background-origin:border-box] [background-clip:padding-box,_border-box]",
"shadow-[inset_0px_2px_1px_rgba(84,84,84,0.15)] backdrop-blur-md",
"transition-all duration-200 hover:shadow-[inset_0px_2px_1px_rgba(84,84,84,0.25)]",
"cursor-pointer min-w-60",
)}
onClick={() => setIsOpen(!isOpen)}
type="button"
>
<Eye className="w-4 h-4 text-slate-300" />
<div className="flex-1 text-left">
<span className="text-sm text-slate-200 font-medium">
{selectedSpace === "all"
? "All Spaces"
: selectedSpace || "Select space"}
</span>
<div className="text-xs text-slate-400">
{selectedSpace === "all"
? `${totalMemories} total memories`
: `${spaceMemoryCounts[selectedSpace] || 0} memories`}
</div>
</div>
<ChevronDown
className={cn(
"w-4 h-4 text-slate-300 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
</button>
{isOpen && (
<div className="absolute top-full left-0 right-0 mt-2 bg-slate-900/95 backdrop-blur-md border border-slate-700/40 rounded-xl shadow-xl z-20 overflow-hidden">
<div className="p-1">

View file

@ -90,6 +90,7 @@ export interface MemoryGraphProps {
showSpacesSelector?: boolean; // true for console, false for consumer
variant?: "console" | "consumer"; // for different positioning and styling
legendId?: string; // Optional ID for the legend component
showLegend?: boolean;
// Optional document highlight list (document custom IDs)
highlightDocumentIds?: string[];
// Whether highlights are currently visible (e.g., chat open)