/**
* MemoryGraph - Memory Graph Visualization
*
* A complete memory graph visualization with the following features:
* - Fit to canvas on load
* - Click to focus on node with smooth pan/zoom
* - Dynamic data changes support
* - Large graph optimization (1000+ nodes)
* - Highlight node links with link reason tooltip
* - 3D text labels on nodes
* - Auto-colored nodes and links by sector/type
* - Directional arrows on links
* - Curved links for better visibility
* - Tooltip with first line of memory on hover
* - Left sidebar with focused memory details and related memories tabs
* - Top-right search and filter panel
* - Focused mode to hide all panels
* - Memory view/edit modals via React Portal
* - Lazy loading: fetch only root node and immediate neighbors initially
* - On-demand expansion: fetch neighbors when clicking unexpanded nodes
* - Node caching: previously fetched nodes are cached
*
* Requirements: 11.1, 11.2, 11.3
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MemoryGraph3D } from "../components/graph/MemoryGraph3D";
import { BlockNotePreview } from "../components/hud/BlockNotePreview";
import { MasonryMemoryCard } from "../components/hud/MasonryMemoryCard";
import { SectorBadge } from "../components/hud/SectorBadge";
import { useGraphLazyLoading } from "../hooks/useGraphLazyLoading";
import { useMemoryStore } from "../stores/memoryStore";
import { themes, useThemeStore } from "../stores/themeStore";
import { useUIStore } from "../stores/uiStore";
import type { GraphNode, Memory, MemorySectorType } from "../types/api";
import type { GraphEdge2D } from "../utils/graphEdges";
import { generateEdges } from "../utils/graphEdges";
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Detect if the user is on macOS
*/
function isMacOS(): boolean {
if (typeof navigator === "undefined") return false;
// Use userAgentData if available (modern browsers), fallback to userAgent
const platform =
(navigator as { userAgentData?: { platform?: string } }).userAgentData?.platform ??
// eslint-disable-next-line @typescript-eslint/no-deprecated
navigator.platform;
return platform.toUpperCase().indexOf("MAC") >= 0;
}
/**
* Get the keyboard shortcut display string based on OS
*/
function getKeyboardShortcutDisplay(): string {
return isMacOS() ? "⌘↵" : "Ctrl+↵";
}
// ============================================================================
// Types
// ============================================================================
export interface MemoryGraphProps {
userId: string;
sessionId: string;
/** Enable lazy loading mode (default: true) - Requirements: 11.1, 11.2, 11.3 */
enableLazyLoading?: boolean;
/** Initial depth for lazy loading (default: 1) - Requirements: 11.5 */
initialDepth?: number;
/** Maximum depth for graph traversal (default: 3) - Requirements: 11.5 */
maxDepth?: number;
/** Root memory ID to center the graph on (optional) */
rootMemoryId?: string;
/** Enable level-of-detail rendering for large graphs (default: true) - Requirements: 11.6 */
enableLOD?: boolean;
/** Threshold for enabling LOD rendering (default: 500) - Requirements: 11.6 */
lodThreshold?: number;
}
// ============================================================================
// Helper Functions
// ============================================================================
function memoryToGraphNode(memory: Memory): GraphNode {
return {
id: memory.id,
content: memory.content,
primarySector: memory.primarySector,
salience: memory.salience,
strength: memory.strength,
createdAt: memory.createdAt,
metadata: memory.metadata,
};
}
// ============================================================================
// Sub-Components
// ============================================================================
interface MemoryDetailsSidebarProps {
focusedMemory: Memory | null;
relatedMemories: RelatedMemoryItem[];
onMemoryClick: (memoryId: string) => void;
onViewMemory: (memory: Memory) => void;
onEditMemory: (memory: Memory) => void;
isLoading: boolean;
}
interface RelatedMemoryItem {
memory: Memory;
connectionType: "direct" | "semantic";
relevanceScore: number;
}
function MemoryDetailsSidebar({
focusedMemory,
relatedMemories,
onMemoryClick,
onViewMemory,
onEditMemory, // Kept for prop compatibility, but we might prefer direct store usage if we refactor props down the line
isLoading,
}: MemoryDetailsSidebarProps): React.ReactElement {
const [activeTab, setActiveTab] = useState<"focused" | "related">("focused");
// Get UI store actions for card clicks
const openMemoryPreview = useUIStore((state) => state.openMemoryPreview);
// Handler for card clicks
const handleCardClick = useCallback(
(memory: Memory) => {
openMemoryPreview(memory);
},
[openMemoryPreview]
);
if (!focusedMemory) {
return (
<div className="w-96 h-full glass-panel-glow rounded-xl p-4 flex flex-col">
<div className="text-center text-ui-text-secondary py-8">
<svg
className="w-12 h-12 mx-auto mb-3 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
/>
</svg>
<p className="text-sm">Click a memory node to view details</p>
</div>
</div>
);
}
return (
<div className="w-96 h-full glass-panel-glow rounded-xl flex flex-col overflow-hidden">
{/* Tab Headers */}
<div className="flex border-b border-ui-border/50 flex-shrink-0">
<button
onClick={(): void => {
setActiveTab("focused");
}}
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
activeTab === "focused"
? "text-ui-accent-primary border-b-2 border-ui-accent-primary bg-ui-accent-primary/5"
: "text-ui-text-secondary hover:text-ui-text-primary"
}`}
>
Focused Memory
</button>
<button
onClick={(): void => {
setActiveTab("related");
}}
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
activeTab === "related"
? "text-ui-accent-primary border-b-2 border-ui-accent-primary bg-ui-accent-primary/5"
: "text-ui-text-secondary hover:text-ui-text-primary"
}`}
>
Related ({relatedMemories.length})
</button>
</div>
{/* Tab Content - flex-1 to fill remaining height */}
<div className="flex-1 overflow-hidden p-4 flex flex-col min-h-0">
{activeTab === "focused" ? (
<div className="flex flex-col h-full min-h-0">
{/* Memory card with scrollable content - takes remaining space minus buttons */}
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
<MasonryMemoryCard
memory={focusedMemory}
onClick={() => {
handleCardClick(focusedMemory);
}}
minHeight={0}
maxHeight={undefined}
className="h-full"
/>
</div>
{/* Action buttons - always visible at bottom */}
<div className="mt-4 flex gap-2 flex-shrink-0">
<button
onClick={() => {
onViewMemory(focusedMemory);
}}
className="flex-1 py-2 px-3 text-sm bg-ui-accent-primary/10 text-ui-accent-primary hover:bg-ui-accent-primary/20 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
View
</button>
<button
onClick={() => {
onEditMemory(focusedMemory);
}}
className="flex-1 py-2 px-3 text-sm border border-ui-border text-ui-text-secondary hover:text-ui-accent-primary hover:border-ui-accent-primary/50 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Edit
</button>
</div>
</div>
) : (
<RelatedMemoriesTab
memories={relatedMemories}
onMemoryClick={onMemoryClick}
onViewMemory={onViewMemory}
isLoading={isLoading}
/>
)}
</div>
</div>
);
}
interface RelatedMemoriesTabProps {
memories: RelatedMemoryItem[];
onMemoryClick: (memoryId: string) => void;
onViewMemory: (memory: Memory) => void;
isLoading: boolean;
}
function RelatedMemoriesTab({
memories,
onMemoryClick,
onViewMemory,
isLoading,
}: RelatedMemoriesTabProps): React.ReactElement {
const containerRef = useRef<HTMLDivElement>(null);
const [useCompactCards, setUseCompactCards] = useState(false);
// Check if content overflows and switch to compact mode if needed
useEffect(() => {
const checkOverflow = (): void => {
if (containerRef.current) {
const { scrollHeight, clientHeight } = containerRef.current;
// If content overflows, use compact cards
setUseCompactCards(scrollHeight > clientHeight + 10); // 10px threshold
}
};
// Check on mount and when memories change
checkOverflow();
// Also check after a short delay to allow content to render
const timer = setTimeout(checkOverflow, 100);
// Add resize observer to recheck on container resize
const resizeObserver = new ResizeObserver(checkOverflow);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return (): void => {
clearTimeout(timer);
resizeObserver.disconnect();
};
}, [memories]);
if (isLoading) {
return (
<div className="space-y-3 h-full">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse">
<div className="h-20 bg-ui-surface-hover rounded-lg" />
</div>
))}
</div>
);
}
if (memories.length === 0) {
return (
<div className="text-center py-8 text-ui-text-secondary h-full flex flex-col justify-center">
<p className="text-sm">No related memories found</p>
<p className="text-xs mt-1 text-ui-text-muted">
Add tags or create links to connect memories
</p>
</div>
);
}
return (
<div ref={containerRef} className="space-y-3 h-full overflow-y-auto custom-scrollbar">
{memories.map((item) => (
<RelatedMemoryCard
key={item.memory.id}
item={item}
compact={useCompactCards}
onNavigate={() => {
onMemoryClick(item.memory.id);
}}
onView={() => {
onViewMemory(item.memory);
}}
/>
))}
</div>
);
}
interface RelatedMemoryCardProps {
item: RelatedMemoryItem;
compact?: boolean;
onNavigate: () => void;
onView: () => void;
}
function RelatedMemoryCard({
item,
compact = false,
onNavigate,
onView,
}: RelatedMemoryCardProps): React.ReactElement {
const { memory, connectionType, relevanceScore } = item;
// Use height-based truncation instead of line-clamp
// Compact mode shows less content height
const maxHeight = compact ? "80px" : "180px";
return (
<div className="p-3 rounded-lg border border-ui-border/50 bg-ui-surface/50 hover:border-ui-accent-primary/30 transition-colors">
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{/* Icon indicator for memory type */}
<SectorBadge sector={memory.primarySector} variant="icon" size="sm" />
<span
className="text-xs font-medium capitalize"
style={{ color: `var(--sector-${memory.primarySector}-color)` }}
>
{memory.primarySector}
</span>
</div>
<span
className={`px-1.5 py-0.5 text-[10px] rounded ${
connectionType === "direct"
? "bg-ui-accent-primary/20 text-ui-accent-primary"
: "bg-purple-500/20 text-purple-400"
}`}
>
{connectionType === "direct" ? "Direct" : "Semantic"}
</span>
</div>
{/* Content - BlockNote rendered preview, truncated by height */}
<div
className={compact ? "mb-2 overflow-hidden" : "mb-3 overflow-hidden"}
style={{ maxHeight }}
>
<BlockNotePreview content={memory.content} />
</div>
{/* Relevance */}
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 h-1 bg-ui-border rounded-full overflow-hidden">
<div
className="h-full bg-ui-accent-primary transition-all"
style={{ width: `${String(Math.round(relevanceScore * 100))}%` }}
/>
</div>
<span className="text-[10px] text-ui-text-muted font-mono">
{String(Math.round(relevanceScore * 100))}%
</span>
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={onView}
className="flex-1 px-2 py-1 text-xs bg-ui-accent-primary/10 hover:bg-ui-accent-primary/20 text-ui-accent-primary rounded transition-colors"
>
View
</button>
<button
onClick={onNavigate}
className="flex-1 px-2 py-1 text-xs border border-ui-border hover:border-ui-accent-primary/50 text-ui-text-secondary hover:text-ui-accent-primary rounded transition-colors"
>
Navigate
</button>
</div>
</div>
);
}
// ============================================================================
// Search FAB Component
// ============================================================================
interface SearchFABProps {
nodes: GraphNode[];
searchQuery: string;
onSearchChange: (query: string) => void;
onResultClick: (nodeId: string) => void;
/** Focus mode state */
isFocusedMode: boolean;
/** Toggle focus mode */
onToggleFocusMode: () => void;
/** Fit to canvas callback */
onFitToCanvas: () => void;
/** Total count of memories (from API) */
totalCount: number | null;
/** Whether more memories are being loaded */
isLoadingMore: boolean;
}
type SearchFABState = "collapsed" | "expanding" | "expanded" | "collapsing";
function SearchFAB({
nodes,
searchQuery,
onSearchChange,
onResultClick,
isFocusedMode,
onToggleFocusMode,
onFitToCanvas,
totalCount,
isLoadingMore,
}: SearchFABProps): React.ReactElement {
const [state, setState] = useState<SearchFABState>("collapsed");
const [showResults, setShowResults] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Animation durations
const EXPAND_DURATION = 250;
const RESULTS_DURATION = 200;
// Search results - show all nodes when query is empty, filter when query exists
const searchResults = useMemo(() => {
if (!searchQuery.trim()) {
// Return all nodes sorted by most recent
return [...nodes].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
const query = searchQuery.toLowerCase();
return nodes.filter((n) => n.content.toLowerCase().includes(query));
}, [nodes, searchQuery]);
// Focus input when expanded
useEffect(() => {
if (state === "expanded" && inputRef.current) {
inputRef.current.focus();
}
}, [state]);
// Scroll to bottom of results when results change
useEffect(() => {
if (showResults && resultsRef.current) {
resultsRef.current.scrollTop = resultsRef.current.scrollHeight;
}
}, [showResults, searchResults]);
// Handle open: FAB → expanding → expanded → show results
const handleOpen = useCallback((): void => {
setState("expanding");
// After search bar expands, show results
setTimeout(() => {
setState("expanded");
// Small delay before showing results for smooth sequence
setTimeout(() => {
setShowResults(true);
}, 50);
}, EXPAND_DURATION);
}, []);
// Handle close: hide results → collapsing → collapsed
const handleClose = useCallback((): void => {
// First hide results (roll down)
setShowResults(false);
// After results are hidden, start collapsing search bar
setTimeout(() => {
setState("collapsing");
onSearchChange("");
// After search bar collapses, return to collapsed state
setTimeout(() => {
setState("collapsed");
}, EXPAND_DURATION);
}, RESULTS_DURATION);
}, [onSearchChange]);
const handleResultClick = useCallback(
(nodeId: string): void => {
// Quick close without full animation for result clicks
setShowResults(false);
setState("collapsed");
onSearchChange("");
onResultClick(nodeId);
},
[onResultClick, onSearchChange]
);
// Handle escape key to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape" && (state === "expanded" || state === "expanding")) {
e.preventDefault();
handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return (): void => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [state, handleClose]);
// Handle click outside to close
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node) &&
state === "expanded"
) {
handleClose();
}
};
if (state === "expanded") {
// Small delay to prevent immediate close on the click that opened it
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
}, 100);
return (): void => {
clearTimeout(timeoutId);
document.removeEventListener("mousedown", handleClickOutside);
};
}
return undefined;
}, [state, handleClose]);
// Handle Cmd+Enter keyboard shortcut to open search
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && state === "collapsed") {
e.preventDefault();
handleOpen();
}
};
window.addEventListener("keydown", handleKeyDown);
return (): void => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [state, handleOpen]);
const isCollapsed = state === "collapsed";
// FAB button (collapsed state)
if (isCollapsed) {
return (
<div className="fixed bottom-[5vh] left-1/2 -translate-x-1/2 z-50 flex items-center gap-3">
{/* Focus Mode Toggle */}
<button
onClick={onToggleFocusMode}
className={`w-12 h-12 rounded-xl transition-all duration-200 flex items-center justify-center hover:scale-105 active:scale-95 ${
isFocusedMode
? "bg-ui-accent-primary text-ui-background shadow-glow"
: "bg-ui-surface-elevated backdrop-blur-sm border border-ui-border/50 text-ui-text-secondary hover:text-ui-accent-primary hover:border-ui-accent-primary/50 shadow-glow-sm"
}`}
title={isFocusedMode ? "Exit Focus Mode" : "Enter Focus Mode"}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isFocusedMode ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
)}
</svg>
</button>
{/* Search FAB */}
<button
onClick={handleOpen}
className="w-48 h-12 rounded-xl bg-ui-surface-elevated text-ui-accent-primary border border-ui-accent-primary/40 shadow-glow hover:shadow-glow-lg transition-all duration-200 flex items-center justify-center gap-2 group hover:scale-105 active:scale-95 hover:border-ui-accent-primary/60"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<span className="font-semibold text-sm">Search</span>
<kbd className="ml-1 px-2 py-1 text-xs font-medium bg-ui-accent-primary-bg text-ui-accent-primary rounded border border-ui-accent-primary/40">
{getKeyboardShortcutDisplay()}
</kbd>
</button>
{/* Fit to View */}
<button
onClick={onFitToCanvas}
className="w-12 h-12 rounded-xl bg-ui-surface-elevated backdrop-blur-sm border border-ui-border/50 shadow-glow-sm transition-all duration-200 flex items-center justify-center text-ui-text-secondary hover:text-ui-accent-primary hover:border-ui-accent-primary/50 hover:scale-105 active:scale-95"
title="Fit graph to view"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
</button>
</div>
);
}
// Expanded/Expanding/Collapsing states
const isExpanding = state === "expanding";
const isCollapsing = state === "collapsing";
return (
<div
ref={containerRef}
className="fixed bottom-[5vh] left-1/2 -translate-x-1/2 z-50 flex flex-col items-center"
style={{ width: "min(600px, 90vw)" }}
>
{/* Search Results - rolls up from search bar */}
<div
className={`w-full mb-3 transition-all ease-out ${
showResults ? "opacity-100" : "opacity-0"
}`}
style={{
maxHeight: showResults ? "calc(100vh - 200px)" : "0px",
transitionDuration: `${String(RESULTS_DURATION)}ms`,
transformOrigin: "bottom center",
}}
>
{searchResults.length > 0 && (
<div
className="w-full flex flex-col"
style={{
maxHeight: "calc(100vh - 200px)",
transform: showResults ? "translateY(0)" : "translateY(20px)",
transition: `transform ${String(RESULTS_DURATION)}ms ease-out`,
background: "var(--theme-surface)",
backdropFilter: "blur(16px)",
border: "1px solid var(--theme-primary-subtle)",
boxShadow:
"0 0 20px var(--theme-primary-glow), 0 0 40px var(--theme-primary-bg), inset 0 0 30px var(--theme-primary-bg)",
borderRadius: "12px",
overflow: "hidden",
}}
>
<div
ref={resultsRef}
className="flex-1 overflow-y-auto custom-scrollbar flex flex-col-reverse min-h-0"
style={{
maxHeight: "calc(100vh - 260px)",
padding: "12px",
}}
>
<div className="space-y-2">
{searchResults.map((node) => (
<SearchResultCard
key={node.id}
node={node}
onClick={() => {
handleResultClick(node.id);
}}
/>
))}
</div>
</div>
<div
className="px-3 py-2 border-t border-ui-border/30 text-xs text-ui-text-muted flex-shrink-0"
style={{
background: "var(--theme-surface-elevated)",
borderBottomLeftRadius: "12px",
borderBottomRightRadius: "12px",
}}
>
{searchQuery
? `${String(searchResults.length)} ${searchResults.length === 1 ? "memory" : "memories"} found`
: totalCount !== null && totalCount > 0
? `${String(totalCount)} ${totalCount === 1 ? "memory" : "memories"} total${nodes.length < totalCount ? ` (${String(nodes.length)} loaded)` : ""}`
: `${String(searchResults.length)} ${searchResults.length === 1 ? "memory" : "memories"} available`}
{isLoadingMore && (
<span className="ml-2 text-ui-accent-primary">
<svg
className="inline-block animate-spin h-3 w-3 mr-1"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
loading...
</span>
)}
<span className="float-right text-ui-text-tertiary">Scroll up for more</span>
</div>
</div>
)}
</div>
{/* Search Input Bar - expands from FAB */}
<div
className="w-full transition-all ease-out"
style={{
transform: isExpanding || isCollapsing ? "scale(0.95)" : "scale(1)",
opacity: isExpanding || isCollapsing ? 0.8 : 1,
transitionDuration: `${String(EXPAND_DURATION)}ms`,
}}
>
<div
className="w-full glass-panel-glow rounded-xl overflow-hidden shadow-glow-lg"
style={{
transform: isExpanding ? "scaleX(0.3)" : isCollapsing ? "scaleX(0.3)" : "scaleX(1)",
transformOrigin: "center center",
transition: `transform ${String(EXPAND_DURATION)}ms ease-out`,
}}
>
<div className="p-3 flex items-center gap-3">
{/* Search Icon */}
<svg
className="w-5 h-5 text-ui-accent-primary flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{/* Input */}
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => {
onSearchChange(e.target.value);
}}
placeholder="Search memories..."
className="flex-1 bg-transparent text-ui-text-primary placeholder-ui-text-muted focus:outline-none text-sm border border-transparent rounded px-2 py-1 -mx-2 -my-1 transition-all duration-normal focus:border-ui-border-active focus:ring-2 focus:ring-ui-accent-primary/20 focus:shadow-glow-sm"
/>
{/* Clear button */}
{searchQuery ? (
<button
onClick={() => {
onSearchChange("");
}}
className="p-1.5 rounded-lg text-ui-text-muted hover:text-ui-text-primary hover:bg-ui-border/30 transition-colors"
title="Clear search"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
) : null}
{/* Escape hint */}
<div className="flex items-center gap-1.5 text-xs text-ui-text-muted flex-shrink-0">
<kbd className="px-1.5 py-0.5 bg-ui-background/50 rounded text-[10px] border border-ui-border/50">
Esc
</kbd>
<span>to close</span>
</div>
</div>
</div>
</div>
</div>
);
}
interface SearchResultCardProps {
node: GraphNode;
onClick: () => void;
}
function SearchResultCard({ node, onClick }: SearchResultCardProps): React.ReactElement {
return (
<div
onMouseDown={(e) => {
e.preventDefault(); // Prevent blur before click
onClick();
}}
className="w-full p-3 text-left text-sm bg-ui-surface/50 hover:bg-ui-accent-primary/10 border border-ui-border/30 hover:border-ui-accent-primary/30 rounded-lg transition-all cursor-pointer group flex flex-col"
>
<div className="flex items-center gap-2 mb-2 flex-shrink-0">
{/* Full pill badge for search results */}
<SectorBadge sector={node.primarySector} variant="pill" size="sm" />
</div>
{/* Content preview - truncated by card container */}
<div
className="text-ui-text-secondary group-hover:text-ui-text-primary transition-colors overflow-hidden"
style={{ maxHeight: "120px" }}
>
<BlockNotePreview content={node.content} />
</div>
</div>
);
}
interface FilterPanelProps {
selectedSectors: MemorySectorType[];
selectedTags: string[];
connectionFilter: "all" | "direct" | "semantic";
onSectorToggle: (sector: MemorySectorType) => void;
onTagToggle: (tag: string) => void;
onConnectionFilterChange: (filter: "all" | "direct" | "semantic") => void;
allTags: string[];
onExpandChange?: (isExpanded: boolean) => void;
}
function FilterPanel({
selectedSectors,
selectedTags,
connectionFilter,
onSectorToggle,
onTagToggle,
onConnectionFilterChange,
allTags,
onExpandChange,
}: FilterPanelProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(false); // Default collapsed for bottom placement
const [isHovered, setIsHovered] = useState(false);
// Panel is active (not translucent) when hovered or expanded
const isActive = isHovered || isExpanded;
const handleToggleExpand = (): void => {
const newExpanded = !isExpanded;
setIsExpanded(newExpanded);
onExpandChange?.(newExpanded);
};
const sectors: MemorySectorType[] = [
"episodic",
"semantic",
"procedural",
"emotional",
"reflective",
];
return (
<div
className={`w-96 rounded-xl overflow-hidden flex flex-col-reverse shadow-lg transition-all duration-300 ${
isActive ? "glass-panel-glow opacity-100" : "bg-ui-surface/30 backdrop-blur-sm opacity-50"
}`}
onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => {
setIsHovered(false);
}}
>
{/* Expand/Collapse Toggle (at bottom because panel is bottom-aligned) */}
<button
onClick={handleToggleExpand}
className="w-full px-3 py-2 flex items-center justify-between text-xs text-ui-text-secondary hover:text-ui-text-primary transition-colors bg-ui-surface/50 font-medium"
>
<span>Filters</span>
<svg
className={`w-4 h-4 transition-transform ${isExpanded ? "" : "rotate-180"}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Filter Content */}
{isExpanded && (
<div className="p-3 space-y-4">
{/* Sector Filters */}
<div>
<label className="text-xs text-ui-text-muted uppercase tracking-wide mb-2 block">
Sectors
</label>
<div className="flex flex-wrap gap-1">
{sectors.map((sector) => {
const isSelected = selectedSectors.includes(sector);
const sectorClass = `sector-badge-${sector}`;
return (
<button
key={sector}
onClick={() => {
onSectorToggle(sector);
}}
className={`px-2 py-1 text-xs rounded-full transition-all ${sectorClass} ${
isSelected
? "ring-1 ring-offset-1 ring-offset-ui-background"
: "opacity-50 hover:opacity-75"
}`}
>
{sector}
</button>
);
})}
</div>
</div>
{/* Connection Type Filter */}
<div>
<label className="text-xs text-ui-text-muted uppercase tracking-wide mb-2 block">
Connection Type
</label>
<div className="flex gap-1">
{(["all", "direct", "semantic"] as const).map((filter) => (
<button
key={filter}
onClick={() => {
onConnectionFilterChange(filter);
}}
className={`flex-1 px-2 py-1.5 text-xs rounded-lg transition-colors ${
connectionFilter === filter
? "bg-ui-accent-primary/20 text-ui-accent-primary"
: "bg-ui-surface/50 text-ui-text-secondary hover:text-ui-text-primary"
}`}
>
{filter === "all" ? "All" : filter === "direct" ? "Direct" : "Semantic"}
</button>
))}
</div>
</div>
{/* Tag Filters */}
{allTags.length > 0 && (
<div>
<label className="text-xs text-ui-text-muted uppercase tracking-wide mb-2 block">
Tags
</label>
<div className="flex flex-wrap gap-1 max-h-24 overflow-y-auto">
{allTags.slice(0, 15).map((tag) => {
const isSelected = selectedTags.includes(tag);
return (
<button
key={tag}
onClick={() => {
onTagToggle(tag);
}}
className={`px-2 py-0.5 text-xs rounded-full transition-colors ${
isSelected
? "bg-ui-accent-primary/20 text-ui-accent-primary"
: "bg-ui-surface-hover text-ui-text-secondary hover:text-ui-text-primary"
}`}
>
#{tag}
</button>
);
})}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ============================================================================
interface GraphRef {
fitToCanvas: () => void;
cameraPosition: (pos: { x: number; y: number; z: number }) => void;
centerAt: (x: number, y: number, z: number) => void;
zoomToFit: (duration?: number, padding?: number) => void;
}
export function MemoryGraph({
userId,
sessionId: _sessionId,
enableLazyLoading = true,
initialDepth = 1,
maxDepth = 3,
rootMemoryId,
enableLOD = true,
lodThreshold = 500,
}: MemoryGraphProps): React.ReactElement {
// Refs
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<GraphRef | null>(null);
// State
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
const [focusedMemory, setFocusedMemory] = useState<Memory | null>(null);
const [isFocusedMode, setIsFocusedMode] = useState(false);
// UI Store actions
const openMemoryPreview = useUIStore((state) => state.openMemoryPreview);
const openMemoryEdit = useUIStore((state) => state.openMemoryEdit);
// Filter state
const [searchQuery, setSearchQuery] = useState("");
const [selectedSectors, setSelectedSectors] = useState<MemorySectorType[]>([
"episodic",
"semantic",
"procedural",
"emotional",
"reflective",
]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [connectionFilter, setConnectionFilter] = useState<"all" | "direct" | "semantic">("all");
const [, setIsFilterExpanded] = useState(false);
// Theme
const currentTheme = useThemeStore((state) => state.currentTheme);
const isLightMode = themes[currentTheme].isLight;
// Store (for non-lazy loading mode and memory lookup)
const memories = useMemoryStore((state) => state.memories);
const fetchMemories = useMemoryStore((state) => state.fetchMemories);
const isLoading = useMemoryStore((state) => state.isLoading);
const isLoadingMore = useMemoryStore((state) => state.isLoadingMore);
const totalCount = useMemoryStore((state) => state.totalCount);
// Lazy loading hook - Requirements: 11.1, 11.2, 11.3
const lazyLoading = useGraphLazyLoading();
// Extract stable references to avoid infinite loops in useEffect
// The hook returns a new object each render, but the functions are stable via useCallback
const {
initializeGraph,
setInitialDepth,
setMaxDepth,
isInitialLoading: lazyIsInitialLoading,
} = lazyLoading;
// Track if we've already initialized to prevent duplicate calls
const hasInitializedRef = useRef(false);
// Initialize lazy loading or fetch all memories based on mode
// Requirements: 11.5 - Configurable depth parameter
useEffect(() => {
if (!userId) return;
// Prevent duplicate initialization
if (hasInitializedRef.current) return;
if (enableLazyLoading) {
// Set initial depth and max depth, then initialize lazy loading graph
setInitialDepth(initialDepth);
setMaxDepth(maxDepth);
hasInitializedRef.current = true;
void initializeGraph(userId, rootMemoryId);
} else {
// Fallback to fetching all memories
hasInitializedRef.current = true;
void fetchMemories(userId);
}
}, [
userId,
enableLazyLoading,
initialDepth,
maxDepth,
rootMemoryId,
fetchMemories,
initializeGraph,
setInitialDepth,
setMaxDepth,
]);
// Reset initialization flag when userId changes
useEffect(() => {
hasInitializedRef.current = false;
}, [userId]);
// Handle escape key to exit focus mode
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape" && isFocusedMode) {
e.preventDefault();
setIsFocusedMode(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return (): void => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isFocusedMode]);
// Handle container resize
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setDimensions({ width: width || 800, height: height || 600 });
}
});
resizeObserver.observe(container);
return (): void => {
resizeObserver.disconnect();
};
}, []);
// Get nodes and edges based on loading mode
const { displayNodes, displayEdges, allMemoriesForLookup } = useMemo((): {
displayNodes: GraphNode[];
displayEdges: GraphEdge2D[];
allMemoriesForLookup: Memory[];
} => {
if (enableLazyLoading) {
// Use lazy-loaded nodes and edges
const lazyNodes = lazyLoading.getNodesArray();
const lazyEdges = lazyLoading.getEdgesArray();
// Convert LazyGraphNode to GraphNode for display
const nodes: GraphNode[] = lazyNodes.map(
(n): GraphNode => ({
id: n.id,
content: n.content,
primarySector: n.primarySector,
salience: n.salience,
strength: n.strength,
createdAt: n.createdAt,
metadata: n.metadata,
})
);
// Convert lazy edges to display format
const edges: GraphEdge2D[] = lazyEdges.map(
(e): GraphEdge2D => ({
source: e.source,
target: e.target,
type: e.type,
weight: e.weight,
})
);
// Create memory lookup from lazy nodes
const memoryLookup: Memory[] = lazyNodes.map(
(n): Memory => ({
id: n.id,
content: n.content,
primarySector: n.primarySector,
salience: n.salience,
strength: n.strength,
createdAt: n.createdAt,
lastAccessed: n.createdAt,
accessCount: 0,
userId: userId,
sessionId: "",
metadata: n.metadata,
})
);
return { displayNodes: nodes, displayEdges: edges, allMemoriesForLookup: memoryLookup };
} else {
// Use all memories from store
const nodes = memories.map(memoryToGraphNode);
const edges = generateEdges(memories);
return { displayNodes: nodes, displayEdges: edges, allMemoriesForLookup: memories };
}
}, [enableLazyLoading, lazyLoading, memories, userId]);
// Extract all unique tags
const allTags = useMemo(() => {
const tagSet = new Set<string>();
allMemoriesForLookup.forEach((m) => {
m.metadata.tags?.forEach((t) => tagSet.add(t));
});
return Array.from(tagSet).sort();
}, [allMemoriesForLookup]);
// Filter nodes
const filteredNodes = useMemo(() => {
let nodes = displayNodes;
// Filter by sectors
nodes = nodes.filter((n) => selectedSectors.includes(n.primarySector));
// Filter by tags
if (selectedTags.length > 0) {
nodes = nodes.filter((n) =>
selectedTags.some((tag) => n.metadata.tags?.includes(tag) === true)
);
}
return nodes;
}, [displayNodes, selectedSectors, selectedTags]);
// Filter edges
const filteredEdges = useMemo((): GraphEdge2D[] => {
const nodeIds = new Set(filteredNodes.map((n) => n.id));
let edges = displayEdges.filter(
(e: GraphEdge2D) => nodeIds.has(e.source) && nodeIds.has(e.target)
);
// Filter by connection type
if (connectionFilter === "direct") {
edges = edges.filter((e: GraphEdge2D) => e.type === "tag" || e.type === "mention");
} else if (connectionFilter === "semantic") {
edges = edges.filter((e: GraphEdge2D) => e.type === "similarity");
}
return edges;
}, [displayEdges, filteredNodes, connectionFilter]);
// Build neighbor maps for highlighting
const { nodeNeighbors, nodeLinks } = useMemo(() => {
const neighbors = new Map<string, Set<string>>();
const links = new Map<string, Set<GraphEdge2D>>();
filteredNodes.forEach((node) => {
neighbors.set(node.id, new Set());
links.set(node.id, new Set());
});
filteredEdges.forEach((link: GraphEdge2D) => {
const sourceId = link.source;
const targetId = link.target;
if (neighbors.has(sourceId) && neighbors.has(targetId)) {
neighbors.get(sourceId)?.add(targetId);
neighbors.get(targetId)?.add(sourceId);
links.get(sourceId)?.add(link);
links.get(targetId)?.add(link);
}
});
return { nodeNeighbors: neighbors, nodeLinks: links };
}, [filteredNodes, filteredEdges]);
// Compute related memories for focused memory
const relatedMemories = useMemo((): RelatedMemoryItem[] => {
if (!focusedMemory) return [];
const related: RelatedMemoryItem[] = [];
const focusedId = focusedMemory.id;
// Find directly connected memories
const directNeighbors = nodeNeighbors.get(focusedId) ?? new Set();
const directLinks = nodeLinks.get(focusedId) ?? new Set();
directNeighbors.forEach((neighborId) => {
const memory = allMemoriesForLookup.find((m) => m.id === neighborId);
if (!memory) return;
// Find the link to determine connection type
let connectionType: "direct" | "semantic" = "direct";
let maxWeight = 0;
directLinks.forEach((link) => {
// GraphEdge2D always has source/target as strings
if (link.source === neighborId || link.target === neighborId) {
if (link.type === "similarity") {
connectionType = "semantic";
}
maxWeight = Math.max(maxWeight, link.weight);
}
});
related.push({
memory,
connectionType,
relevanceScore: maxWeight || 0.5,
});
});
// Sort by relevance
return related.sort((a, b) => b.relevanceScore - a.relevanceScore);
}, [focusedMemory, allMemoriesForLookup, nodeNeighbors, nodeLinks]);
// Sector toggle handler
const handleSectorToggle = useCallback((sector: MemorySectorType) => {
setSelectedSectors((prev) => {
if (prev.includes(sector)) {
if (prev.length === 1) return prev; // Don't allow empty
return prev.filter((s) => s !== sector);
}
return [...prev, sector];
});
}, []);
// Tag toggle handler
const handleTagToggle = useCallback((tag: string) => {
setSelectedTags((prev) => {
if (prev.includes(tag)) {
return prev.filter((t) => t !== tag);
}
return [...prev, tag];
});
}, []);
// Navigate to memory from sidebar
const handleMemoryNavigate = useCallback(
(memoryId: string) => {
const memory = allMemoriesForLookup.find((m) => m.id === memoryId);
if (memory) {
setFocusedMemory(memory);
// If lazy loading is enabled and node can be expanded, expand it
if (enableLazyLoading && lazyLoading.canExpandNode(memoryId)) {
void lazyLoading.expandNode(memoryId);
}
}
},
[allMemoriesForLookup, enableLazyLoading, lazyLoading]
);
/**
* Handle node click - focus on node and expand if lazy loading is enabled
* Requirements: 11.2 - Fetch neighbors on node expansion
*/
const handleNodeClick = useCallback(
(nodeId: string) => {
const memory = allMemoriesForLookup.find((m) => m.id === nodeId);
if (memory) {
setFocusedMemory(memory);
// If lazy loading is enabled and node can be expanded, expand it
// Requirements: 11.2
if (enableLazyLoading && lazyLoading.canExpandNode(nodeId)) {
void lazyLoading.expandNode(nodeId);
}
}
},
[allMemoriesForLookup, enableLazyLoading, lazyLoading]
);
// View memory modal
const handleViewMemory = useCallback(
(memory: Memory) => {
openMemoryPreview(memory);
},
[openMemoryPreview]
);
// Edit memory modal
const handleEditMemory = useCallback(
(memory: Memory) => {
openMemoryEdit(memory);
},
[openMemoryEdit]
);
return (
<div className="h-full relative" ref={containerRef}>
{/* Loading overlay for initial lazy load */}
{enableLazyLoading && lazyIsInitialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-ui-background/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3">
<svg
className="animate-spin h-8 w-8 text-ui-accent-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
<span className="text-sm text-ui-text-secondary">Loading graph...</span>
</div>
</div>
)}
<MemoryGraph3D
nodes={filteredNodes}
edges={filteredEdges}
selectedNodeId={focusedMemory?.id ?? null}
onNodeClick={handleNodeClick}
onNodeHover={() => {
// Hover highlighting is handled internally by MemoryGraph3D
}}
onLinkHover={() => {
// Optional: Handle link hover if needed specifically
}}
width={dimensions.width}
height={dimensions.height}
lightMode={isLightMode}
showNavInfo={false} // We have our own controls
fitToCanvas={true}
graphRef={graphRef}
onBackgroundClick={() => {
setFocusedMemory(null);
}}
enableLOD={enableLOD}
lodThreshold={lodThreshold}
/>
{/* LOD Mode Indicator - Requirements: 11.6 */}
{enableLOD && filteredNodes.length > lodThreshold && !isFocusedMode && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-40">
<div className="glass-panel-glow rounded-lg px-3 py-2 flex items-center gap-2 text-xs text-ui-text-secondary">
<svg
className="w-4 h-4 text-ui-accent-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span>LOD Mode: {filteredNodes.length} nodes</span>
<span className="text-ui-text-muted">(simplified rendering)</span>
</div>
</div>
)}
{/* Expanding nodes indicator */}
{enableLazyLoading && lazyLoading.expandingNodeIds.size > 0 && (
<div className="absolute top-4 right-4 z-40">
<div className="glass-panel-glow rounded-lg px-3 py-2 flex items-center gap-2 text-sm text-ui-text-secondary">
<svg
className="animate-spin h-4 w-4 text-ui-accent-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
<span>Loading neighbors...</span>
</div>
</div>
)}
{/* Left Sidebar - Memory Details */}
{!isFocusedMode && focusedMemory && (
<div className="absolute top-4 left-4 bottom-4 z-40">
<MemoryDetailsSidebar
focusedMemory={focusedMemory}
relatedMemories={relatedMemories}
onMemoryClick={handleMemoryNavigate}
onViewMemory={handleViewMemory}
onEditMemory={handleEditMemory}
isLoading={enableLazyLoading ? lazyIsInitialLoading : isLoading}
/>
</div>
)}
{/* Bottom Center - Search FAB */}
{!isFocusedMode && (
<SearchFAB
nodes={filteredNodes}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onResultClick={handleMemoryNavigate}
isFocusedMode={isFocusedMode}
onToggleFocusMode={() => {
setIsFocusedMode(!isFocusedMode);
}}
onFitToCanvas={() => {
if (graphRef.current) {
graphRef.current.fitToCanvas();
}
}}
totalCount={enableLazyLoading ? filteredNodes.length : totalCount}
isLoadingMore={enableLazyLoading ? lazyLoading.expandingNodeIds.size > 0 : isLoadingMore}
/>
)}
{/* Focus Mode Exit Indicator */}
{isFocusedMode && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-40">
<button
onClick={() => {
setIsFocusedMode(false);
}}
className="glass-panel-glow rounded-xl px-4 py-2.5 flex items-center gap-3 text-ui-text-secondary hover:text-ui-accent-primary transition-colors group"
>
<span className="text-sm">Focus Mode</span>
<span className="flex items-center gap-1.5 text-xs text-ui-text-muted group-hover:text-ui-accent-primary">
<kbd className="px-1.5 py-0.5 bg-ui-background/50 rounded text-[10px] border border-ui-border/50">
Esc
</kbd>
<span>to exit</span>
</span>
</button>
</div>
)}
{/* Bottom Right - Filter Panel (separate control) */}
{!isFocusedMode && (
<div className="absolute bottom-4 right-4 z-40">
<FilterPanel
selectedSectors={selectedSectors}
selectedTags={selectedTags}
connectionFilter={connectionFilter}
onSectorToggle={handleSectorToggle}
onTagToggle={handleTagToggle}
onConnectionFilterChange={setConnectionFilter}
allTags={allTags}
onExpandChange={setIsFilterExpanded}
/>
</div>
)}
</div>
);
}
export default MemoryGraph;