/** * RelatedNotesSidebar - Semantically similar documents panel. * * Aesthetic: Specimen cabinet / archive drawer * - Related notes displayed like catalogued specimens * - Similarity scores as teal "analysis bars" under glass * - Subtle brass accents (old gold) on interactive elements * - Live updates as content changes (debounced 500ms) * - 30s client-side cache for performance */ import { ChevronDownIcon, ChevronRightIcon, LinkIcon, SparklesIcon, XIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { apiFetch } from "../hooks/use-api"; import { cn } from "../lib/utils"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "./ui/collapsible"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; // ----------------------------------------------------------------------------- // Types // ----------------------------------------------------------------------------- interface SimilarDoc { docid: string; uri: string; title: string; collection: string; score: number; } interface SimilarResponse { similar: SimilarDoc[]; meta: { docid: string; totalResults: number; limit: number; threshold: number; }; } export interface RelatedNotesSidebarProps { /** Document ID to find similar docs for */ docId: string; /** Current editor content for live updates */ content?: string; /** Max results to show (default 5) */ limit?: number; /** Minimum similarity threshold (default 0.5) */ threshold?: number; /** Navigate to related document */ onNavigate: (uri: string) => void; /** Additional classes */ className?: string; } // ----------------------------------------------------------------------------- // Cache (30s TTL) // ----------------------------------------------------------------------------- interface CacheEntry { data: SimilarResponse; timestamp: number; } const cache = new Map(); const CACHE_TTL = 30000; // 30 seconds function getCached(key: string): SimilarResponse | null { const entry = cache.get(key); if (!entry) return null; if (Date.now() - entry.timestamp > CACHE_TTL) { cache.delete(key); return null; } return entry.data; } function setCache(key: string, data: SimilarResponse): void { cache.set(key, { data, timestamp: Date.now() }); } function buildCacheKey( docId: string, limit: number, threshold: number, contentHash?: string ): string { return `similar:${docId}:${limit}:${threshold}:${contentHash ?? "static"}`; } // Simple content hash for cache invalidation function hashContent(content: string): string { let hash = 0; for (const char of content) { hash = (hash << 5) - hash + char.charCodeAt(0); hash = hash & hash; // Convert to 32-bit integer } return hash.toString(36); } // ----------------------------------------------------------------------------- // Debounce hook // ----------------------------------------------------------------------------- function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } // ----------------------------------------------------------------------------- // Skeleton loader // ----------------------------------------------------------------------------- function RelatedNotesSkeleton() { return (
{[1, 2, 3].map((i) => (
{/* Title skeleton */}
{/* Collection badge skeleton */}
{/* Score bar skeleton */}
))}
); } // ----------------------------------------------------------------------------- // Empty state // ----------------------------------------------------------------------------- function RelatedNotesEmpty() { return (

No related notes found

Similar documents will appear here as you write

); } // ----------------------------------------------------------------------------- // Similarity score bar - "analysis bar under glass" // ----------------------------------------------------------------------------- function SimilarityBar({ score }: { score: number }) { const percentage = Math.round(score * 100); return (
{/* Bar track */}
{percentage}%
); } // ----------------------------------------------------------------------------- // Related note item - specimen card style // ----------------------------------------------------------------------------- function RelatedNoteItem({ doc, onNavigate, index, }: { doc: SimilarDoc; onNavigate: () => void; index: number; }) { return ( ); } // ----------------------------------------------------------------------------- // Main component // ----------------------------------------------------------------------------- export function RelatedNotesSidebar({ docId, content, limit = 5, threshold = 0.5, onNavigate, className, }: RelatedNotesSidebarProps) { const [similar, setSimilar] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isOpen, setIsOpen] = useState(true); const [isVisible, setIsVisible] = useState(true); // Request sequencing to prevent race conditions const requestIdRef = useRef(0); // Debounce content changes (500ms) const debouncedContent = useDebounce(content, 500); const contentHash = useMemo( () => (debouncedContent ? hashContent(debouncedContent) : undefined), [debouncedContent] ); // Build cache key const cacheKey = useMemo( () => buildCacheKey(docId, limit, threshold, contentHash), [docId, limit, threshold, contentHash] ); // Fetch similar documents const fetchSimilar = useCallback(async () => { // Generate request ID for sequencing const currentRequestId = ++requestIdRef.current; // Check cache first const cached = getCached(cacheKey); if (cached) { setSimilar(cached.similar); setLoading(false); return; } setLoading(true); setError(null); const params = new URLSearchParams({ limit: String(limit), threshold: String(threshold), }); const url = `/api/doc/${encodeURIComponent(docId)}/similar?${params.toString()}`; const { data, error: fetchError } = await apiFetch(url); // Check if this request is still the latest if (currentRequestId !== requestIdRef.current) { return; // Stale request, ignore } if (fetchError || !data) { setError(fetchError ?? "Failed to load related notes"); setLoading(false); return; } // Cache and update state setCache(cacheKey, data); setSimilar(data.similar); setLoading(false); }, [cacheKey, docId, limit, threshold]); // Fetch on mount and when dependencies change useEffect(() => { void fetchSimilar(); }, [fetchSimilar]); // Toggle visibility (on/off capability) const handleToggleVisibility = useCallback(() => { setIsVisible((v) => !v); }, []); // If hidden, show minimal toggle if (!isVisible) { return (
); } return (
{/* Header */}
{/* Chevron */} {isOpen ? ( ) : ( )} {/* Title */} Related Notes {/* Count badge */} {similar.length > 0 && ( {similar.length} )} {/* Hide button */}
{/* Content */} {loading ? ( ) : error ? (

{error}

) : similar.length === 0 ? ( ) : (
{similar.map((doc, index) => ( onNavigate(doc.uri)} /> ))}
)}
); }