ui (memory detail): improved memory detail view and open chat

This commit is contained in:
Mahesh Sanikommmu 2025-08-23 00:38:57 -07:00
parent 6d6767bdca
commit 3816666e2d
6 changed files with 757 additions and 758 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,375 @@
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 { colors } from '@repo/ui/memory-graph/constants';
import type { DocumentsWithMemoriesResponseSchema } from '@repo/validation/api';
import { Badge } from '@ui/components/badge';
import { Brain, Calendar, ExternalLink, Sparkles } from 'lucide-react';
import { memo, useState } 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 [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.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 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.content}
</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="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, index) => (
<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>
<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,
}}
>
<div
className="p-6 relative"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<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>
);
}
);

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

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

@ -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}
/>