This commit is contained in:
Dhravya Shah 2025-08-26 15:34:09 -07:00
commit c6d455363d
12 changed files with 1062 additions and 973 deletions

View file

@ -388,284 +388,284 @@ 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 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>
<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>
<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
documents={allDocuments}
error={error}
hasMore={hasMore}
isLoading={isPending}
isLoadingMore={isLoadingMore}
legendId={TOUR_STEP_IDS.LEGEND}
loadMoreDocuments={loadMoreDocuments}
showSpacesSelector={false}
totalLoaded={totalLoaded}
variant="consumer"
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isOpen}
occludedRightPx={isOpen && !isMobile ? 600 : 0}
autoLoadOnViewport={false}
isExperimental={isCurrentProjectExperimental}
>
<div className="absolute inset-0 flex items-center justify-center">
<div className="rounded-xl overflow-hidden">
<div className="relative z-10 text-slate-200 px-6 py-4 text-center">
<p className="text-lg font-medium mb-2">
No Memories to Visualize
</p>
<button
type="button"
className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline"
onClick={() => setShowAddMemoryView(true)}
>
Create one?
</button>
</div>
</div>
</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">
<div className="rounded-xl overflow-hidden">
<div className="relative z-10 text-slate-200 px-6 py-4 text-center">
<p className="text-lg font-medium mb-2">
No Memories to Visualize
</p>
<button
className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline"
onClick={() => setShowAddMemoryView(true)}
type="button"
>
Create one?
</button>
</div>
</div>
</div>
</MemoryListView>
</motion.div>
)}
</AnimatePresence>
{/* 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
documents={allDocuments}
error={error}
hasMore={hasMore}
isLoading={isPending}
isLoadingMore={isLoadingMore}
legendId={TOUR_STEP_IDS.LEGEND}
loadMoreDocuments={loadMoreDocuments}
showSpacesSelector={false}
totalLoaded={totalLoaded}
variant="consumer"
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isOpen}
occludedRightPx={isOpen && !isMobile ? 600 : 0}
autoLoadOnViewport={false}
isExperimental={isCurrentProjectExperimental}
>
<div className="absolute inset-0 flex items-center justify-center">
<div className="rounded-xl overflow-hidden">
<div className="relative z-10 text-slate-200 px-6 py-4 text-center">
<p className="text-lg font-medium mb-2">
No Memories to Visualize
</p>
<button
type="button"
className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline"
onClick={() => setShowAddMemoryView(true)}
>
Create one?
</button>
</div>
</div>
</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">
<div className="rounded-xl overflow-hidden">
<div className="relative z-10 text-slate-200 px-6 py-4 text-center">
<p className="text-lg font-medium mb-2">
No Memories to Visualize
</p>
<button
className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline"
onClick={() => setShowAddMemoryView(true)}
type="button"
>
Create one?
</button>
</div>
</div>
</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>
{/* 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>
<div className="hidden sm:block">
<ProjectSelector />
</div>
<ConnectAIModal>
<Button
variant="outline"
size="sm"
className="bg-white/5 hover:bg-white/10 border-white/20 text-white hover:text-white px-2 sm:px-3"
>
<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>
<ConnectAIModal>
<Button
variant="outline"
size="sm"
className="bg-white/5 hover:bg-white/10 border-white/20 text-white hover:text-white px-2 sm:px-3"
>
<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>
<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="h-14 px-4 bg-blue-600 hover:bg-blue-700 text-white shadow-lg hover:shadow-xl transition-all duration-200 rounded-full flex items-center gap-2"
onClick={() => setIsOpen(true)}
size="lg"
>
<MessageSquare className="h-5 w-5" />
<span className="font-medium">Open Chat</span>
</Button>
</motion.div>
)}
</motion.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>
)}
</motion.div>
{/* Chat panel - positioned absolutely */}
<motion.div
className="fixed top-0 right-0 h-full z-50 md:z-auto"
style={{
width: isOpen ? (isMobile ? "100vw" : "600px") : 0,
pointerEvents: isOpen ? "auto" : "none",
}}
id={TOUR_STEP_IDS.FLOATING_CHAT}
>
<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>
{/* Chat panel - positioned absolutely */}
<motion.div
className="fixed top-0 right-0 h-full z-50 md:z-auto"
style={{
width: isOpen ? (isMobile ? '100vw' : '600px') : 0,
pointerEvents: isOpen ? 'auto' : 'none',
}}
id={TOUR_STEP_IDS.FLOATING_CHAT}
>
<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 && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
{showAddMemoryView && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
{/* Tour Alert Dialog */}
<TourAlertDialog onOpenChange={setShowTourDialog} open={showTourDialog} />
{/* Tour Alert Dialog */}
<TourAlertDialog onOpenChange={setShowTourDialog} open={showTourDialog} />
{/* Referral/Upgrade Modal */}
<ReferralUpgradeModal
isOpen={showReferralModal}
onClose={() => setShowReferralModal(false)}
/>
</div>
);
{/* Referral/Upgrade Modal */}
<ReferralUpgradeModal
isOpen={showReferralModal}
onClose={() => setShowReferralModal(false)}
/>
</div>
);
};
// Wrapper component to handle auth and waitlist checks

View file

@ -0,0 +1,53 @@
import type { DocumentWithMemories } from "@ui/memory-graph/types";
export const formatDate = (date: string | Date) => {
const dateObj = new Date(date);
const now = new Date();
const currentYear = now.getFullYear();
const dateYear = dateObj.getFullYear();
const monthNames = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const month = monthNames[dateObj.getMonth()];
const day = dateObj.getDate();
const getOrdinalSuffix = (n: number) => {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]!);
};
const formattedDay = getOrdinalSuffix(day);
if (dateYear !== currentYear) {
return `${month} ${formattedDay}, ${dateYear}`;
}
return `${month} ${formattedDay}`;
};
export const getSourceUrl = (document: DocumentWithMemories) => {
if (document.type === "google_doc" && document.customId) {
return `https://docs.google.com/document/d/${document.customId}`;
}
if (document.type === "google_sheet" && document.customId) {
return `https://docs.google.com/spreadsheets/d/${document.customId}`;
}
if (document.type === "google_slide" && document.customId) {
return `https://docs.google.com/presentation/d/${document.customId}`;
}
// Fallback to existing URL for all other document types
return document.url;
};

View file

@ -0,0 +1,415 @@
import { getDocumentIcon } from '@/lib/document-icon';
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from '@repo/ui/components/drawer';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@repo/ui/components/sheet';
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from '@repo/ui/components/tabs';
import { colors } from '@repo/ui/memory-graph/constants';
import type { DocumentsWithMemoriesResponseSchema } from '@repo/validation/api';
import { Badge } from '@ui/components/badge';
import { Brain, Calendar, CircleUserRound, ExternalLink, List, Sparkles } from 'lucide-react';
import { memo } from 'react';
import type { z } from 'zod';
import { formatDate, getSourceUrl } from '.';
import { Label1Regular } from '@ui/text/label/label-1-regular';
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>;
type DocumentWithMemories = DocumentsResponse['documents'][0];
type MemoryEntry = DocumentWithMemories['memoryEntries'][0];
const formatDocumentType = (type: string) => {
// Special case for PDF
if (type.toLowerCase() === 'pdf') return 'PDF';
// Replace underscores with spaces and capitalize each word
return type
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => {
return (
<button
className="p-4 rounded-lg transition-all relative overflow-hidden cursor-pointer"
style={{
backgroundColor: memory.isLatest
? colors.memory.primary
: 'rgba(255, 255, 255, 0.02)',
}}
tabIndex={0}
type="button"
>
<div className="flex items-start gap-2 relative z-10">
<div
className="p-1 rounded"
style={{
backgroundColor: memory.isLatest
? colors.memory.secondary
: 'transparent',
}}
>
<Brain
className={`w-4 h-4 flex-shrink-0 transition-all ${
memory.isLatest ? 'text-blue-400' : 'text-blue-400/50'
}`}
/>
</div>
<div className="flex-1 space-y-2">
<Label1Regular
className="text-sm leading-relaxed text-left"
style={{ color: colors.text.primary }}
>
{memory.memory}
</Label1Regular>
<div className="flex gap-2 justify-between">
<div
className="flex items-center gap-4 text-xs"
style={{ color: colors.text.muted }}
>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(memory.createdAt)}
</span>
<span className="font-mono">v{memory.version}</span>
{memory.sourceRelevanceScore && (
<span
className="flex items-center gap-1"
style={{
color:
memory.sourceRelevanceScore > 70
? colors.accent.emerald
: colors.text.muted,
}}
>
<Sparkles className="w-3 h-3" />
{memory.sourceRelevanceScore}%
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{memory.isForgotten && (
<Badge
className="text-xs border-red-500/30 backdrop-blur-sm"
style={{
backgroundColor: colors.status.forgotten,
color: '#dc2626',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
variant="destructive"
>
Forgotten
</Badge>
)}
{memory.isLatest && (
<Badge
className="text-xs"
style={{
backgroundColor: colors.memory.secondary,
color: colors.text.primary,
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
variant="default"
>
Latest
</Badge>
)}
{memory.forgetAfter && (
<Badge
className="text-xs backdrop-blur-sm"
style={{
color: colors.status.expiring,
backgroundColor: 'rgba(251, 165, 36, 0.1)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
variant="outline"
>
Expires: {formatDate(memory.forgetAfter)}
</Badge>
)}
</div>
</div>
</div>
</div>
</button>
);
});
export const MemoryDetail = memo(
({
document,
isOpen,
onClose,
isMobile,
}: {
document: DocumentWithMemories | null;
isOpen: boolean;
onClose: () => void;
isMobile: boolean;
}) => {
if (!document) return null;
const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten);
const forgottenMemories = document.memoryEntries.filter(
(m) => m.isForgotten
);
const HeaderContent = ({
TitleComponent,
}: {
TitleComponent: typeof SheetTitle | typeof DrawerTitle;
}) => (
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 flex-1">
<div
className="p-2 rounded-lg"
style={{
backgroundColor: colors.background.secondary,
}}
>
{getDocumentIcon(document.type, 'w-5 h-5')}
</div>
<div className="flex-1">
<TitleComponent style={{ color: colors.text.primary }}>
{document.title || 'Untitled Document'}
</TitleComponent>
<div
className="flex items-center gap-2 mt-1 text-xs"
style={{ color: colors.text.muted }}
>
<span>{formatDocumentType(document.type)}</span>
<span></span>
<span>{formatDate(document.createdAt)}</span>
{document.url && (
<>
<span></span>
<button
className="flex items-center gap-1 transition-all hover:gap-2"
onClick={() => {
const sourceUrl = getSourceUrl(document);
window.open(sourceUrl ?? undefined, '_blank');
}}
style={{ color: colors.accent.primary }}
type="button"
>
View source
<ExternalLink className="w-3 h-3" />
</button>
</>
)}
</div>
</div>
</div>
</div>
);
const ContentAndSummarySection = () => {
const hasContent = document.content && document.content.trim().length > 0;
const hasSummary = document.summary && document.summary.trim().length > 0;
if (!hasContent && !hasSummary) return null;
const defaultTab = hasContent ? 'content' : 'summary';
return (
<div className="mt-4">
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList
className={`grid w-full bg-white/5 border border-white/10 h-11 ${
hasContent && hasSummary ? 'grid-cols-2' : 'grid-cols-1'
}`}
>
{hasContent && (
<TabsTrigger
value="content"
className="text-xs bg-transparent h-8"
style={{ color: colors.text.secondary }}
>
<CircleUserRound className="w-3 h-3" />
Original Content
</TabsTrigger>
)}
{hasSummary && (
<TabsTrigger
value="summary"
className="text-xs flex items-center gap-1 bg-transparent h-8"
style={{ color: colors.text.secondary }}
>
<List className="w-3 h-3" />
Summary
</TabsTrigger>
)}
</TabsList>
{hasContent && (
<TabsContent value="content" className="mt-3">
<div className="p-3 rounded-lg max-h-48 overflow-y-auto custom-scrollbar bg-white/[0.03] border border-white/[0.08]">
<p
className="text-sm leading-relaxed whitespace-pre-wrap"
style={{ color: colors.text.primary }}
>
{document.content}
</p>
</div>
</TabsContent>
)}
{hasSummary && (
<TabsContent value="summary" className="mt-3">
<div className="p-3 rounded-lg max-h-48 overflow-y-auto custom-scrollbar bg-indigo-500/5 border border-indigo-500/15">
<p
className="text-sm leading-relaxed whitespace-pre-wrap"
style={{ color: colors.text.muted }}
>
{document.summary}
</p>
</div>
</TabsContent>
)}
</Tabs>
</div>
);
};
const MemoryContent = () => (
<div className="space-y-6 px-6">
{activeMemories.length > 0 && (
<div>
<div
className="text-sm font-medium mb-2 flex items-start gap-2 py-2"
style={{
color: colors.text.secondary,
}}
>
Active Memories ({activeMemories.length})
</div>
<div className="space-y-3">
{activeMemories.map((memory) => (
<div
key={memory.id}
>
<MemoryDetailItem memory={memory} />
</div>
))}
</div>
</div>
)}
{forgottenMemories.length > 0 && (
<div>
<div
className="text-sm font-medium mb-4 px-3 py-2 rounded-lg opacity-60"
style={{
color: colors.text.muted,
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
Forgotten Memories ({forgottenMemories.length})
</div>
<div className="space-y-3 opacity-40">
{forgottenMemories.map((memory) => (
<MemoryDetailItem key={memory.id} memory={memory} />
))}
</div>
</div>
)}
{activeMemories.length === 0 && forgottenMemories.length === 0 && (
<div
className="text-center py-12 rounded-lg"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<Brain
className="w-12 h-12 mx-auto mb-4 opacity-30"
style={{ color: colors.text.muted }}
/>
<p style={{ color: colors.text.muted }}>
No memories found for this document
</p>
</div>
)}
</div>
);
if (isMobile) {
return (
<Drawer onOpenChange={onClose} open={isOpen}>
<DrawerContent
className="border-0 p-0 overflow-hidden max-h-[90vh]"
style={{
backgroundColor: colors.background.secondary,
borderTop: `1px solid ${colors.document.border}`,
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
{/* Header section with glass effect */}
<div
className="p-4 relative border-b"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
borderBottom: `1px solid ${colors.document.border}`,
}}
>
<DrawerHeader className="pb-0 px-0 text-left">
<HeaderContent TitleComponent={DrawerTitle} />
</DrawerHeader>
<ContentAndSummarySection />
</div>
<div className="flex-1 memory-drawer-scroll overflow-y-auto">
<MemoryContent />
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Sheet onOpenChange={onClose} open={isOpen}>
<SheetContent
className="w-full sm:max-w-2xl border-0 p-0 overflow-hidden"
style={{
backgroundColor: colors.background.secondary,
}}
>
<div
className="p-6 relative"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<SheetHeader className="pb-0">
<HeaderContent TitleComponent={SheetTitle} />
</SheetHeader>
<ContentAndSummarySection />
</div>
<div className="h-[calc(100vh-200px)] memory-sheet-scroll overflow-y-auto">
<MemoryContent />
</div>
</SheetContent>
</Sheet>
);
}
);

View file

@ -2,42 +2,14 @@
import { useIsMobile } from "@hooks/use-mobile";
import { cn } from "@lib/utils";
import {
GoogleDocs,
GoogleDrive,
GoogleSheets,
GoogleSlides,
MicrosoftExcel,
MicrosoftOneNote,
MicrosoftPowerpoint,
MicrosoftWord,
NotionDoc,
OneDrive,
PDF,
} from "@repo/ui/assets/icons";
import { Badge } from "@repo/ui/components/badge";
import { Card, CardContent, CardHeader } from "@repo/ui/components/card";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@repo/ui/components/drawer";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@repo/ui/components/sheet";
import { colors } from "@repo/ui/memory-graph/constants";
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Label1Regular } from "@ui/text/label/label-1-regular";
import {
Brain,
Calendar,
ExternalLink,
FileText,
Sparkles,
} from "lucide-react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -45,9 +17,12 @@ import type { z } from "zod";
import useResizeObserver from "@/hooks/use-resize-observer";
import { analytics } from "@/lib/analytics";
import { MemoryDetail } from "./memories/memory-detail";
import { getDocumentIcon } from "@/lib/document-icon";
import { formatDate, getSourceUrl } from "./memories";
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>;
type DocumentWithMemories = DocumentsResponse["documents"][0];
type MemoryEntry = DocumentWithMemories["memoryEntries"][0];
interface MemoryListViewProps {
children?: React.ReactNode;
@ -85,222 +60,6 @@ const GreetingMessage = memo(() => {
);
});
const formatDate = (date: string | Date) => {
const dateObj = new Date(date);
const now = new Date();
const currentYear = now.getFullYear();
const dateYear = dateObj.getFullYear();
const monthNames = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const month = monthNames[dateObj.getMonth()];
const day = dateObj.getDate();
const getOrdinalSuffix = (n: number) => {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]!);
};
const formattedDay = getOrdinalSuffix(day);
if (dateYear !== currentYear) {
return `${month} ${formattedDay}, ${dateYear}`;
}
return `${month} ${formattedDay}`;
};
const formatDocumentType = (type: string) => {
// Special case for PDF
if (type.toLowerCase() === "pdf") return "PDF";
// Replace underscores with spaces and capitalize each word
return type
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
};
const getDocumentIcon = (type: string, className: string) => {
const iconProps = {
className,
style: { color: colors.text.muted },
};
switch (type) {
case "google_doc":
return <GoogleDocs {...iconProps} />;
case "google_sheet":
return <GoogleSheets {...iconProps} />;
case "google_slide":
return <GoogleSlides {...iconProps} />;
case "google_drive":
return <GoogleDrive {...iconProps} />;
case "notion":
case "notion_doc":
return <NotionDoc {...iconProps} />;
case "word":
case "microsoft_word":
return <MicrosoftWord {...iconProps} />;
case "excel":
case "microsoft_excel":
return <MicrosoftExcel {...iconProps} />;
case "powerpoint":
case "microsoft_powerpoint":
return <MicrosoftPowerpoint {...iconProps} />;
case "onenote":
case "microsoft_onenote":
return <MicrosoftOneNote {...iconProps} />;
case "onedrive":
return <OneDrive {...iconProps} />;
case "pdf":
return <PDF {...iconProps} />;
default:
return <FileText {...iconProps} />;
}
};
const getSourceUrl = (document: DocumentWithMemories) => {
if (document.type === "google_doc" && document.customId) {
return `https://docs.google.com/document/d/${document.customId}`;
}
if (document.type === "google_sheet" && document.customId) {
return `https://docs.google.com/spreadsheets/d/${document.customId}`;
}
if (document.type === "google_slide" && document.customId) {
return `https://docs.google.com/presentation/d/${document.customId}`;
}
// Fallback to existing URL for all other document types
return document.url;
};
const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => {
return (
<button
className="p-4 rounded-lg border transition-all relative overflow-hidden cursor-pointer"
style={{
backgroundColor: memory.isLatest
? colors.memory.primary
: "rgba(255, 255, 255, 0.02)",
borderColor: memory.isLatest
? colors.memory.border
: "rgba(255, 255, 255, 0.1)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
}}
tabIndex={0}
type="button"
>
<div className="flex items-start gap-2 relative z-10">
<div
className="p-1 rounded"
style={{
backgroundColor: memory.isLatest
? colors.memory.secondary
: "transparent",
}}
>
<Brain
className={`w-4 h-4 flex-shrink-0 transition-all ${
memory.isLatest ? "text-blue-400" : "text-blue-400/50"
}`}
/>
</div>
<div className="flex-1 space-y-2">
<Label1Regular
className="text-sm leading-relaxed text-left"
style={{ color: colors.text.primary }}
>
{memory.memory}
</Label1Regular>
<div className="flex items-center gap-2 flex-wrap">
{memory.isForgotten && (
<Badge
className="text-xs border-red-500/30 backdrop-blur-sm"
style={{
backgroundColor: colors.status.forgotten,
color: "#dc2626",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
}}
variant="destructive"
>
Forgotten
</Badge>
)}
{memory.isLatest && (
<Badge
className="text-xs border-blue-400/30 backdrop-blur-sm"
style={{
backgroundColor: colors.memory.secondary,
color: colors.accent.primary,
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
}}
variant="default"
>
Latest
</Badge>
)}
{memory.forgetAfter && (
<Badge
className="text-xs backdrop-blur-sm"
style={{
borderColor: colors.status.expiring,
color: colors.status.expiring,
backgroundColor: "rgba(251, 165, 36, 0.1)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
}}
variant="outline"
>
Expires: {formatDate(memory.forgetAfter)}
</Badge>
)}
</div>
<div
className="flex items-center gap-4 text-xs"
style={{ color: colors.text.muted }}
>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(memory.createdAt)}
</span>
<span className="font-mono">v{memory.version}</span>
{memory.sourceRelevanceScore && (
<span
className="flex items-center gap-1"
style={{
color:
memory.sourceRelevanceScore > 70
? colors.accent.emerald
: colors.text.muted,
}}
>
<Sparkles className="w-3 h-3" />
{memory.sourceRelevanceScore}%
</span>
)}
</div>
</div>
</div>
</button>
);
});
const DocumentCard = memo(
({
document,
@ -361,12 +120,12 @@ const DocumentCard = memo(
</div>
</CardHeader>
<CardContent className="relative z-10 px-0">
{document.summary && (
{document.content && (
<p
className="text-xs line-clamp-2 mb-3"
style={{ color: colors.text.muted }}
>
{document.summary}
{document.content}
</p>
)}
<div className="flex items-center gap-2 flex-wrap">
@ -402,248 +161,6 @@ const DocumentCard = memo(
},
);
const DocumentDetailSheet = memo(
({
document,
isOpen,
onClose,
isMobile,
}: {
document: DocumentWithMemories | null;
isOpen: boolean;
onClose: () => void;
isMobile: boolean;
}) => {
if (!document) return null;
const [isSummaryExpanded, setIsSummaryExpanded] = useState(false);
const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten);
const forgottenMemories = document.memoryEntries.filter(
(m) => m.isForgotten,
);
const HeaderContent = ({
TitleComponent,
}: {
TitleComponent: typeof SheetTitle | typeof DrawerTitle;
}) => (
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 flex-1">
<div
className="p-2 rounded-lg"
style={{
backgroundColor: colors.document.secondary,
border: `1px solid ${colors.document.border}`,
}}
>
{getDocumentIcon(document.type, "w-5 h-5")}
</div>
<div className="flex-1">
<TitleComponent style={{ color: colors.text.primary }}>
{document.title || "Untitled Document"}
</TitleComponent>
<div
className="flex items-center gap-2 mt-1 text-xs"
style={{ color: colors.text.muted }}
>
<span>{formatDocumentType(document.type)}</span>
<span></span>
<span>{formatDate(document.createdAt)}</span>
{document.url && (
<>
<span></span>
<button
className="flex items-center gap-1 transition-all hover:gap-2"
onClick={() => {
const sourceUrl = getSourceUrl(document);
window.open(sourceUrl ?? undefined, "_blank");
}}
style={{ color: colors.accent.primary }}
type="button"
>
View source
<ExternalLink className="w-3 h-3" />
</button>
</>
)}
</div>
</div>
</div>
</div>
);
const SummarySection = () => {
if (!document.summary) return null;
const shouldShowToggle = document.summary.length > 200; // Show toggle for longer summaries
return (
<div
className="mt-4 p-3 rounded-lg"
style={{
backgroundColor: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
}}
>
<p
className={`text-sm ${!isSummaryExpanded ? "line-clamp-3" : ""}`}
style={{ color: colors.text.muted }}
>
{document.summary}
</p>
{shouldShowToggle && (
<button
onClick={() => setIsSummaryExpanded(!isSummaryExpanded)}
className="mt-2 text-xs hover:underline transition-all"
style={{ color: colors.accent.primary }}
type="button"
>
{isSummaryExpanded ? "Show less" : "Show more"}
</button>
)}
</div>
);
};
const MemoryContent = () => (
<div className="p-6 space-y-6">
{activeMemories.length > 0 && (
<div>
<div
className="text-sm font-medium mb-4 flex items-start gap-2 px-3 py-2 rounded-lg"
style={{
color: colors.text.secondary,
backgroundColor: colors.memory.primary,
border: `1px solid ${colors.memory.border}`,
}}
>
<Brain className="w-4 h-4 text-blue-400" />
Active Memories ({activeMemories.length})
</div>
<div className="space-y-3">
{activeMemories.map((memory, index) => (
<div
className="animate-in fade-in slide-in-from-right-2"
key={memory.id}
style={{ animationDelay: `${index * 50}ms` }}
>
<MemoryDetailItem memory={memory} />
</div>
))}
</div>
</div>
)}
{forgottenMemories.length > 0 && (
<div>
<div
className="text-sm font-medium mb-4 px-3 py-2 rounded-lg opacity-60"
style={{
color: colors.text.muted,
backgroundColor: "rgba(255, 255, 255, 0.02)",
border: "1px solid rgba(255, 255, 255, 0.08)",
}}
>
Forgotten Memories ({forgottenMemories.length})
</div>
<div className="space-y-3 opacity-40">
{forgottenMemories.map((memory) => (
<MemoryDetailItem key={memory.id} memory={memory} />
))}
</div>
</div>
)}
{activeMemories.length === 0 && forgottenMemories.length === 0 && (
<div
className="text-center py-12 rounded-lg"
style={{
backgroundColor: "rgba(255, 255, 255, 0.02)",
border: "1px solid rgba(255, 255, 255, 0.08)",
}}
>
<Brain
className="w-12 h-12 mx-auto mb-4 opacity-30"
style={{ color: colors.text.muted }}
/>
<p style={{ color: colors.text.muted }}>
No memories found for this document
</p>
</div>
)}
</div>
);
if (isMobile) {
return (
<Drawer onOpenChange={onClose} open={isOpen}>
<DrawerContent
className="border-0 p-0 overflow-hidden max-h-[90vh]"
style={{
backgroundColor: colors.background.secondary,
borderTop: `1px solid ${colors.document.border}`,
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
}}
>
{/* Header section with glass effect */}
<div
className="p-4 relative border-b"
style={{
backgroundColor: "rgba(255, 255, 255, 0.02)",
borderBottom: `1px solid ${colors.document.border}`,
}}
>
<DrawerHeader className="pb-0 px-0 text-left">
<HeaderContent TitleComponent={DrawerTitle} />
</DrawerHeader>
<SummarySection />
</div>
<div className="flex-1 memory-drawer-scroll overflow-y-auto">
<MemoryContent />
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Sheet onOpenChange={onClose} open={isOpen}>
<SheetContent
className="w-full sm:max-w-2xl border-0 p-0 overflow-hidden"
style={{
backgroundColor: colors.background.secondary,
borderLeft: `1px solid ${colors.document.border}`,
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
}}
>
{/* Header section with glass effect */}
<div
className="p-6 relative"
style={{
backgroundColor: "rgba(255, 255, 255, 0.02)",
borderBottom: `1px solid ${colors.document.border}`,
}}
>
<SheetHeader className="pb-0">
<HeaderContent TitleComponent={SheetTitle} />
</SheetHeader>
<SummarySection />
</div>
<div className="h-[calc(100vh-200px)] memory-sheet-scroll overflow-y-auto">
<MemoryContent />
</div>
</SheetContent>
</Sheet>
);
},
);
export const MemoryListView = ({
children,
documents,
@ -831,7 +348,7 @@ export const MemoryListView = ({
)}
</div>
<DocumentDetailSheet
<MemoryDetail
document={selectedDocument}
isOpen={isDetailOpen}
onClose={handleCloseDetails}

View file

@ -151,7 +151,7 @@ function Menu({ id }: { id?: string }) {
}, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded]);
// Calculate width based on state
const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56;
const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 220 : 56;
// Dynamic z-index for mobile based on active panel
const mobileZIndex =
@ -275,26 +275,33 @@ function Menu({ id }: { id?: string }) {
opacity: isHovered ? 1 : 0,
x: isHovered ? 0 : -10,
}}
className="drop-shadow-lg absolute left-10 whitespace-nowrap flex items-center gap-2"
className="drop-shadow-lg absolute left-10 right-16 whitespace-nowrap"
initial={{ opacity: 0, x: -10 }}
style={{
transform: "translateZ(0)",
}}
transition={{
duration: 0.3,
delay: index * 0.03,
duration: isHovered ? 0.2 : 0.1,
delay: isHovered ? index * 0.03 : 0,
ease: [0.4, 0, 0.2, 1],
}}
>
{item.text}
{/* Show warning indicator for Add Memory when limits approached */}
{shouldShowLimitWarning &&
item.key === "addUrl" && (
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded">
{memoriesLimit - memoriesUsed} left
</span>
)}
</motion.p>
{shouldShowLimitWarning && item.key === "addUrl" && (
<motion.span
animate={{ opacity: isHovered ? 1 : 0, x: isHovered ? 0 : -10 }}
initial={{ opacity: 0, x: -10 }}
transition={{
duration: isHovered ? 0.2 : 0.1,
delay: isHovered ? index * 0.03 : 0,
ease: [0.4, 0, 0.2, 1],
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded"
>
{memoriesLimit - memoriesUsed} left
</motion.span>
)}
</motion.button>
{index === 0 && (
<motion.div
@ -460,6 +467,12 @@ function Menu({ id }: { id?: string }) {
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40 z-[60]" />
<Drawer.Content className="bg-transparent fixed bottom-0 left-0 right-0 z-[70] outline-none">
<Drawer.Title className="sr-only">
{expandedView === "addUrl" && "Add Memory"}
{expandedView === "mcp" && "Model Context Protocol"}
{expandedView === "profile" && "Profile"}
{!expandedView && "Menu"}
</Drawer.Title>
<div className="w-full flex flex-col text-sm font-medium shadow-2xl relative overflow-hidden rounded-t-3xl max-h-[80vh]">
{/* Glass effect background */}
<div className="absolute inset-0 rounded-t-3xl">

View file

@ -1,13 +0,0 @@
import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}
export const onRequestError = Sentry.captureRequestError;

View file

@ -0,0 +1,54 @@
import { colors } from '@repo/ui/memory-graph/constants';
import {
GoogleDocs,
MicrosoftWord,
NotionDoc,
GoogleDrive,
GoogleSheets,
GoogleSlides,
PDF,
OneDrive,
MicrosoftOneNote,
MicrosoftPowerpoint,
MicrosoftExcel,
} from '@ui/assets/icons';
import { FileText } from 'lucide-react';
export const getDocumentIcon = (type: string, className: string) => {
const iconProps = {
className,
style: { color: colors.text.muted },
};
switch (type) {
case 'google_doc':
return <GoogleDocs {...iconProps} />;
case 'google_sheet':
return <GoogleSheets {...iconProps} />;
case 'google_slide':
return <GoogleSlides {...iconProps} />;
case 'google_drive':
return <GoogleDrive {...iconProps} />;
case 'notion':
case 'notion_doc':
return <NotionDoc {...iconProps} />;
case 'word':
case 'microsoft_word':
return <MicrosoftWord {...iconProps} />;
case 'excel':
case 'microsoft_excel':
return <MicrosoftExcel {...iconProps} />;
case 'powerpoint':
case 'microsoft_powerpoint':
return <MicrosoftPowerpoint {...iconProps} />;
case 'onenote':
case 'microsoft_onenote':
return <MicrosoftOneNote {...iconProps} />;
case 'onedrive':
return <OneDrive {...iconProps} />;
case 'pdf':
return <PDF {...iconProps} />;
default:
return <FileText {...iconProps} />;
}
};

View file

@ -1,19 +0,0 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://2451ebfd1a7490f05fa7776482df81b6@o4508385422802944.ingest.us.sentry.io/4509872269819904",
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View file

@ -1,18 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://2451ebfd1a7490f05fa7776482df81b6@o4508385422802944.ingest.us.sentry.io/4509872269819904",
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View file

@ -33,6 +33,40 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [session?.session.activeOrganizationId])
// When a session exists and there is a pending login method recorded,
// promote it to the last-used method (successful login) and clear pending.
useEffect(() => {
if (typeof window === "undefined") return
if (!session?.session) return
try {
const pendingMethod = localStorage.getItem(
"supermemory-pending-login-method",
)
const pendingTsRaw = localStorage.getItem(
"supermemory-pending-login-timestamp",
)
if (pendingMethod) {
const now = Date.now()
const ts = pendingTsRaw ? Number.parseInt(pendingTsRaw, 10) : NaN
const isFresh = Number.isFinite(ts) && now - ts < 10 * 60 * 1000 // 10 minutes TTL
if (isFresh) {
localStorage.setItem(
"supermemory-last-login-method",
pendingMethod,
)
}
}
} catch { }
// Always clear pending markers once a session is present
try {
localStorage.removeItem("supermemory-pending-login-method")
localStorage.removeItem("supermemory-pending-login-timestamp")
} catch { }
}, [session?.session])
const setActiveOrg = async (slug: string) => {
if (!slug) return

View file

@ -83,7 +83,7 @@ function SheetContent({
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-1.5 p-4", className)}
className={cn("flex flex-col gap-1.5 py-4", className)}
data-slot="sheet-header"
{...props}
/>

View file

@ -1,25 +1,26 @@
"use client";
"use client"
import { signIn } from "@lib/auth";
import { usePostHog } from "@lib/posthog";
import { LogoFull } from "@repo/ui/assets/Logo";
import { TextSeparator } from "@repo/ui/components/text-separator";
import { ExternalAuthButton } from "@ui/button/external-auth";
import { Button } from "@ui/components/button";
import { signIn } from "@lib/auth"
import { usePostHog } from "@lib/posthog"
import { LogoFull } from "@repo/ui/assets/Logo"
import { TextSeparator } from "@repo/ui/components/text-separator"
import { ExternalAuthButton } from "@ui/button/external-auth"
import { Button } from "@ui/components/button"
import { Badge } from "@ui/components/badge"
import {
Carousel,
CarouselContent,
CarouselItem,
} from "@ui/components/carousel";
import { LabeledInput } from "@ui/input/labeled-input";
import { HeadingH1Medium } from "@ui/text/heading/heading-h1-medium";
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium";
import { Label1Regular } from "@ui/text/label/label-1-regular";
import { Title1Bold } from "@ui/text/title/title-1-bold";
import Autoplay from "embla-carousel-autoplay";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
} from "@ui/components/carousel"
import { LabeledInput } from "@ui/input/labeled-input"
import { HeadingH1Medium } from "@ui/text/heading/heading-h1-medium"
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
import { Label1Regular } from "@ui/text/label/label-1-regular"
import { Title1Bold } from "@ui/text/title/title-1-bold"
import Autoplay from "embla-carousel-autoplay"
import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import { useState, useEffect } from "react"
export function LoginPage({
heroText = "The unified memory API for the AI era.",
@ -28,74 +29,101 @@ export function LoginPage({
"Trusted by Open Source, enterprise and developers.",
],
}) {
const [email, setEmail] = useState("");
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingEmail, setIsLoadingEmail] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const [email, setEmail] = useState("")
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
const router = useRouter()
const posthog = usePostHog()
const params = useSearchParams()
// Load last used method from localStorage on mount
useEffect(() => {
const savedMethod = localStorage.getItem('supermemory-last-login-method')
setLastUsedMethod(savedMethod)
}, [])
// Record the pending login method (will be committed after successful auth)
function setPendingLoginMethod(method: string) {
try {
localStorage.setItem('supermemory-pending-login-method', method)
localStorage.setItem('supermemory-pending-login-timestamp', String(Date.now()))
} catch { }
}
// If we land back on this page with an error, clear any pending marker
useEffect(() => {
if (params.get("error")) {
try {
localStorage.removeItem('supermemory-pending-login-method')
localStorage.removeItem('supermemory-pending-login-timestamp')
} catch { }
}
}, [params])
const posthog = usePostHog();
const params = useSearchParams();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setIsLoadingEmail(true);
setError(null);
e.preventDefault()
setIsLoading(true)
setIsLoadingEmail(true)
setError(null)
// Track login attempt
posthog.capture("login_attempt", {
method: "magic_link",
email_domain: email.split("@")[1] || "unknown",
});
})
try {
await signIn.magicLink({
callbackURL: window.location.origin,
email,
});
setSubmittedEmail(email);
})
setSubmittedEmail(email)
setPendingLoginMethod('magic_link')
// Track successful magic link send
posthog.capture("login_magic_link_sent", {
email_domain: email.split("@")[1] || "unknown",
});
})
} catch (error) {
console.error(error);
console.error(error)
// Track login failure
posthog.capture("login_failed", {
method: "magic_link",
error: error instanceof Error ? error.message : "Unknown error",
email_domain: email.split("@")[1] || "unknown",
});
})
setError(
error instanceof Error
? error.message
: "Failed to send login link. Please try again.",
);
setIsLoading(false);
setIsLoadingEmail(false);
return;
)
setIsLoading(false)
setIsLoadingEmail(false)
return
}
setIsLoading(false);
setIsLoadingEmail(false);
};
setIsLoading(false)
setIsLoadingEmail(false)
}
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsLoading(true);
event.preventDefault()
setIsLoading(true)
const formData = new FormData(event.currentTarget);
const token = formData.get("token") as string;
const formData = new FormData(event.currentTarget)
const token = formData.get("token") as string
router.push(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(window.location.host)}`,
);
};
)
}
return (
<section className="min-h-screen flex flex-col lg:grid lg:grid-cols-12 items-center justify-center p-4 sm:p-6 md:p-8 lg:px-[5rem] lg:py-[3.125rem] gap-6 lg:gap-[5rem] max-w-[400rem] mx-auto">
@ -197,8 +225,8 @@ export function LoginPage({
disabled: isLoading,
id: "email",
onChange: (e) => {
setEmail(e.target.value);
error && setError(null);
setEmail(e.target.value)
error && setError(null)
},
required: true,
value: email,
@ -207,124 +235,149 @@ export function LoginPage({
label="Email"
/>
<Button className="w-full" disabled={isLoading} type="submit">
{isLoadingEmail
? "Sending login link..."
: "Log in to supermemory"}
</Button>
<div className="relative">
<Button className="w-full" disabled={isLoading} type="submit">
{isLoadingEmail
? "Sending login link..."
: "Log in to supermemory"}
</Button>
{lastUsedMethod === 'magic_link' && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">Last used</Badge>
</div>
)}
</div>
</div>
</form>
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
<TextSeparator text="OR" />
) : null}
<div className="flex flex-col sm:flex-row flex-wrap gap-3 lg:gap-4">
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
<ExternalAuthButton
authIcon={
<svg
className="w-4 h-4 sm:w-5 sm:h-5"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Google</title>
<path
d="M21.8055 10.2563H21V10.2148H12V14.2148H17.6515C16.827 16.5433 14.6115 18.2148 12 18.2148C8.6865 18.2148 6 15.5283 6 12.2148C6 8.90134 8.6865 6.21484 12 6.21484C13.5295 6.21484 14.921 6.79184 15.9805 7.73434L18.809 4.90584C17.023 3.24134 14.634 2.21484 12 2.21484C6.4775 2.21484 2 6.69234 2 12.2148C2 17.7373 6.4775 22.2148 12 22.2148C17.5225 22.2148 22 17.7373 22 12.2148C22 11.5443 21.931 10.8898 21.8055 10.2563Z"
fill="#FFC107"
/>
<path
d="M3.15234 7.56034L6.43784 9.96984C7.32684 7.76884 9.47984 6.21484 11.9993 6.21484C13.5288 6.21484 14.9203 6.79184 15.9798 7.73434L18.8083 4.90584C17.0223 3.24134 14.6333 2.21484 11.9993 2.21484C8.15834 2.21484 4.82734 4.38334 3.15234 7.56034Z"
fill="#FF3D00"
/>
<path
d="M12.0002 22.2152C14.5832 22.2152 16.9302 21.2267 18.7047 19.6192L15.6097 17.0002C14.5721 17.7897 13.3039 18.2166 12.0002 18.2152C9.39916 18.2152 7.19066 16.5567 6.35866 14.2422L3.09766 16.7547C4.75266 19.9932 8.11366 22.2152 12.0002 22.2152Z"
fill="#4CAF50"
/>
<path
d="M21.8055 10.2563H21V10.2148H12V14.2148H17.6515C17.2571 15.3231 16.5467 16.2914 15.608 17.0003L15.6095 16.9993L18.7045 19.6183C18.4855 19.8173 22 17.2148 22 12.2148C22 11.5443 21.931 10.8898 21.8055 10.2563Z"
fill="#1976D2"
/>
</svg>
}
authProvider="Google"
disabled={isLoading}
onClick={() => {
if (isLoading) return;
setIsLoading(true);
posthog.capture("login_attempt", {
method: "social",
provider: "google",
});
signIn
.social({
callbackURL: window.location.origin,
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
<div className="relative flex-grow">
<ExternalAuthButton
authIcon={
<svg
className="w-4 h-4 sm:w-5 sm:h-5"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Google</title>
<path
d="M21.8055 10.2563H21V10.2148H12V14.2148H17.6515C16.827 16.5433 14.6115 18.2148 12 18.2148C8.6865 18.2148 6 15.5283 6 12.2148C6 8.90134 8.6865 6.21484 12 6.21484C13.5295 6.21484 14.921 6.79184 15.9805 7.73434L18.809 4.90584C17.023 3.24134 14.634 2.21484 12 2.21484C6.4775 2.21484 2 6.69234 2 12.2148C2 17.7373 6.4775 22.2148 12 22.2148C17.5225 22.2148 22 17.7373 22 12.2148C22 11.5443 21.931 10.8898 21.8055 10.2563Z"
fill="#FFC107"
/>
<path
d="M3.15234 7.56034L6.43784 9.96984C7.32684 7.76884 9.47984 6.21484 11.9993 6.21484C13.5288 6.21484 14.9203 6.79184 15.9798 7.73434L18.8083 4.90584C17.0223 3.24134 14.6333 2.21484 11.9993 2.21484C8.15834 2.21484 4.82734 4.38334 3.15234 7.56034Z"
fill="#FF3D00"
/>
<path
d="M12.0002 22.2152C14.5832 22.2152 16.9302 21.2267 18.7047 19.6192L15.6097 17.0002C14.5721 17.7897 13.3039 18.2166 12.0002 18.2152C9.39916 18.2152 7.19066 16.5567 6.35866 14.2422L3.09766 16.7547C4.75266 19.9932 8.11366 22.2152 12.0002 22.2152Z"
fill="#4CAF50"
/>
<path
d="M21.8055 10.2563H21V10.2148H12V14.2148H17.6515C17.2571 15.3231 16.5467 16.2914 15.608 17.0003L15.6095 16.9993L18.7045 19.6183C18.4855 19.8173 22 17.2148 22 12.2148C22 11.5443 21.931 10.8898 21.8055 10.2563Z"
fill="#1976D2"
/>
</svg>
}
authProvider="Google"
className="w-full"
disabled={isLoading}
onClick={() => {
if (isLoading) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "google",
})
.finally(() => {
setIsLoading(false);
});
}}
/>
setPendingLoginMethod('google')
signIn
.social({
callbackURL: window.location.origin,
provider: "google",
})
.finally(() => {
setIsLoading(false)
})
}}
/>
{lastUsedMethod === 'google' && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">Last used</Badge>
</div>
)}
</div>
) : null}
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
<ExternalAuthButton
authIcon={
<svg
className="w-4 h-4 sm:w-5 sm:h-5"
fill="none"
height="25"
viewBox="0 0 26 25"
width="26"
xmlns="http://www.w3.org/2000/svg"
>
<title>Github</title>
<g clipPath="url(#clip0_2579_3356)">
<path
clipRule="evenodd"
d="M12.9635 0.214844C6.20975 0.214844 0.75 5.71484 0.75 12.5191C0.75 17.9581 4.24825 22.5621 9.10125 24.1916C9.708 24.3141 9.93025 23.9268 9.93025 23.6011C9.93025 23.3158 9.91025 22.3381 9.91025 21.3193C6.51275 22.0528 5.80525 19.8526 5.80525 19.8526C5.25925 18.4266 4.45025 18.0601 4.45025 18.0601C3.33825 17.3063 4.53125 17.3063 4.53125 17.3063C5.76475 17.3878 6.412 18.5693 6.412 18.5693C7.50375 20.4433 9.263 19.9138 9.97075 19.5878C10.0718 18.7933 10.3955 18.2433 10.7393 17.9378C8.0295 17.6526 5.1785 16.5933 5.1785 11.8671C5.1785 10.5226 5.6635 9.42259 6.432 8.56709C6.31075 8.26159 5.886 6.99834 6.5535 5.30759C6.5535 5.30759 7.58475 4.98159 9.91 6.57059C10.9055 6.30126 11.9322 6.16425 12.9635 6.16309C13.9948 6.16309 15.046 6.30584 16.0168 6.57059C18.3423 4.98159 19.3735 5.30759 19.3735 5.30759C20.041 6.99834 19.616 8.26159 19.4948 8.56709C20.2835 9.42259 20.7485 10.5226 20.7485 11.8671C20.7485 16.5933 17.8975 17.6321 15.1675 17.9378C15.6125 18.3248 15.9965 19.0581 15.9965 20.2193C15.9965 21.8693 15.9765 23.1936 15.9765 23.6008C15.9765 23.9268 16.199 24.3141 16.8055 24.1918C21.6585 22.5618 25.1568 17.9581 25.1568 12.5191C25.1768 5.71484 19.697 0.214844 12.9635 0.214844Z"
fill="white"
fillRule="evenodd"
/>
</g>
<defs>
<clipPath id="clip0_2579_3356">
<rect
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
<div className="relative flex-grow">
<ExternalAuthButton
authIcon={
<svg
className="w-4 h-4 sm:w-5 sm:h-5"
fill="none"
height="25"
viewBox="0 0 26 25"
width="26"
xmlns="http://www.w3.org/2000/svg"
>
<title>Github</title>
<g clipPath="url(#clip0_2579_3356)">
<path
clipRule="evenodd"
d="M12.9635 0.214844C6.20975 0.214844 0.75 5.71484 0.75 12.5191C0.75 17.9581 4.24825 22.5621 9.10125 24.1916C9.708 24.3141 9.93025 23.9268 9.93025 23.6011C9.93025 23.3158 9.91025 22.3381 9.91025 21.3193C6.51275 22.0528 5.80525 19.8526 5.80525 19.8526C5.25925 18.4266 4.45025 18.0601 4.45025 18.0601C3.33825 17.3063 4.53125 17.3063 4.53125 17.3063C5.76475 17.3878 6.412 18.5693 6.412 18.5693C7.50375 20.4433 9.263 19.9138 9.97075 19.5878C10.0718 18.7933 10.3955 18.2433 10.7393 17.9378C8.0295 17.6526 5.1785 16.5933 5.1785 11.8671C5.1785 10.5226 5.6635 9.42259 6.432 8.56709C6.31075 8.26159 5.886 6.99834 6.5535 5.30759C6.5535 5.30759 7.58475 4.98159 9.91 6.57059C10.9055 6.30126 11.9322 6.16425 12.9635 6.16309C13.9948 6.16309 15.046 6.30584 16.0168 6.57059C18.3423 4.98159 19.3735 5.30759 19.3735 5.30759C20.041 6.99834 19.616 8.26159 19.4948 8.56709C20.2835 9.42259 20.7485 10.5226 20.7485 11.8671C20.7485 16.5933 17.8975 17.6321 15.1675 17.9378C15.6125 18.3248 15.9965 19.0581 15.9965 20.2193C15.9965 21.8693 15.9765 23.1936 15.9765 23.6008C15.9765 23.9268 16.199 24.3141 16.8055 24.1918C21.6585 22.5618 25.1568 17.9581 25.1568 12.5191C25.1768 5.71484 19.697 0.214844 12.9635 0.214844Z"
fill="white"
height="24"
transform="translate(0.75 0.214844)"
width="24.5"
fillRule="evenodd"
/>
</clipPath>
</defs>
</svg>
}
authProvider="Github"
disabled={isLoading}
onClick={() => {
if (isLoading) return;
setIsLoading(true);
posthog.capture("login_attempt", {
method: "social",
provider: "github",
});
signIn
.social({
callbackURL: window.location.origin,
</g>
<defs>
<clipPath id="clip0_2579_3356">
<rect
fill="white"
height="24"
transform="translate(0.75 0.214844)"
width="24.5"
/>
</clipPath>
</defs>
</svg>
}
authProvider="Github"
className="w-full"
disabled={isLoading}
onClick={() => {
if (isLoading) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "github",
})
.finally(() => {
setIsLoading(false);
});
}}
/>
setPendingLoginMethod('github')
signIn
.social({
callbackURL: window.location.origin,
provider: "github",
})
.finally(() => {
setIsLoading(false)
})
}}
/>
{lastUsedMethod === 'github' && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">Last used</Badge>
</div>
)}
</div>
) : null}
</div>
@ -350,5 +403,5 @@ export function LoginPage({
</div>
)}
</section>
);
}
)
}