// Phase 2D — Library Search overlay. // // AlphaSense-inspired faceted Cmd+K across the entire brain. Type a query, // see ranked results from every layer with snippet previews and highlighted // match terms. Click a facet chip ("Frameworks", "Stocks", "Documents") to // scope. Click a result to open the markdown in the Brain tab editor. // // Backed by /api/research/library-search which reuses IdeasVault.search() // (already returns hit-scored results with snippets + facet counts). const { useState: lsUseState, useEffect: lsUseEffect, useRef: lsUseRef, useCallback: lsUseCallback, useMemo: lsUseMemo, } = React; // Friendly facet labels for the chip strip. Order matters — this is the // order the chips render in. `documents` is a synthetic facet that the // backend computes from any 4-Stocks/Documents/* hit. const FACETS = [ { key: '', label: 'All', hint: 'Everything in the brain' }, { key: '1-Frameworks', label: 'Frameworks', hint: 'Investing frameworks + mental models' }, { key: '2-Science & Tech Sectors', label: 'Sectors', hint: 'Per-sector deep dives' }, { key: '3-Hedging & Macro', label: 'Macro', hint: 'Hedging frameworks + current regime' }, { key: '4-Stocks', label: 'Stocks', hint: 'Per-ticker deep dives + watchlist + open positions' }, { key: 'documents', label: 'Documents', hint: 'SEC filings + earnings + shareholder letters (corpus)' }, { key: '5-Newsletters', label: 'Newsletters', hint: 'External research, morning briefs' }, { key: '7-Jarvis Captures', label: 'Captures', hint: 'Tibeb chat captures + ad-hoc notes' }, { key: 'agents', label: 'Agents', hint: 'Custom + built-in analyst prompts' }, ]; function LibrarySearch({ open, onClose, onOpenNote }) { if (!open) return null; const [query, setQuery] = lsUseState(''); const [activeFacet, setActiveFacet] = lsUseState(''); const [results, setResults] = lsUseState([]); const [facets, setFacets] = lsUseState({}); const [totalHits, setTotalHits] = lsUseState(0); const [loading, setLoading] = lsUseState(false); const [err, setErr] = lsUseState(''); const [activeIdx, setActiveIdx] = lsUseState(0); // Phase 3.C — saved searches: shown in the empty state for quick re-run const [savedSearches, setSavedSearches] = lsUseState([]); const [savingNow, setSavingNow] = lsUseState(false); const isPersistedUser = !!(window.TIBEB_USER && window.TIBEB_USER.auth_configured && !window.TIBEB_USER.is_local); const inputRef = lsUseRef(null); // Autofocus + reset when (re)opened. Also fetch saved searches once // per open so they appear in the empty state. lsUseEffect(() => { if (open) { setActiveIdx(0); setTimeout(() => inputRef.current && inputRef.current.focus(), 30); if (isPersistedUser) { fetch('/api/saved-searches').then(r => r.ok ? r.json() : null).then(j => { if (j && Array.isArray(j.saved)) setSavedSearches(j.saved); }).catch(() => {}); } } }, [open, isPersistedUser]); // Save current query as a named search. Auto-name uses the query text. const saveCurrent = lsUseCallback(async () => { const q = query.trim(); if (!q || savingNow) return; const name = q.length > 40 ? q.slice(0, 40) + '…' : q; setSavingNow(true); try { const r = await fetch('/api/saved-searches', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, query: q, layer: activeFacet || null }), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const j = await r.json(); setSavedSearches(prev => [{ id: j.id, name, query: q, layer: activeFacet || null, created: new Date().toISOString(), last_run: null }, ...prev]); } catch (e) { setErr(`Save failed — ${e.message || e}`); } finally { setSavingNow(false); } }, [query, activeFacet, savingNow]); // Run a saved search: load its query/layer + stamp last_run server-side. const runSaved = lsUseCallback((s) => { if (!s) return; setQuery(s.query || ''); setActiveFacet(s.layer || ''); if (s.id) { fetch(`/api/saved-searches/${s.id}/touch`, { method: 'POST' }).catch(() => {}); } }, []); const deleteSaved = lsUseCallback(async (s) => { if (!s || !s.id) return; try { const r = await fetch(`/api/saved-searches/${s.id}`, { method: 'DELETE' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); setSavedSearches(prev => prev.filter(x => x.id !== s.id)); } catch (e) { setErr(`Delete failed — ${e.message || e}`); } }, []); // Currently-saved? (so the Save button can flip to a checkmark) const isAlreadySaved = lsUseMemo(() => { const q = query.trim(); if (!q) return false; return savedSearches.some(s => s.query === q && (s.layer || '') === (activeFacet || '')); }, [query, activeFacet, savedSearches]); // Debounced search. Empty query → clear results. lsUseEffect(() => { if (!query.trim()) { setResults([]); setTotalHits(0); setLoading(false); setErr(''); return; } setLoading(true); setErr(''); const handle = setTimeout(async () => { try { const params = new URLSearchParams({ q: query, limit: '40' }); if (activeFacet) params.set('layer', activeFacet); const r = await fetch(`/api/research/library-search?${params}`); if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`HTTP ${r.status} ${t.slice(0, 80)}`); } const j = await r.json(); setResults(j.results || []); setFacets(j.facets || {}); setTotalHits(j.total_hits || 0); setActiveIdx(0); } catch (e) { setErr(e.message || String(e)); setResults([]); } finally { setLoading(false); } }, 180); return () => clearTimeout(handle); }, [query, activeFacet]); // Keyboard navigation: ↑/↓ move selection, Enter opens, Esc closes. lsUseEffect(() => { if (!open) return; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); return; } if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, Math.max(0, results.length - 1))); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, 0)); } else if (e.key === 'Enter' && results[activeIdx]) { e.preventDefault(); openResult(results[activeIdx]); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, results, activeIdx, onClose]); const openResult = lsUseCallback((r) => { if (!r) return; if (typeof onOpenNote === 'function') onOpenNote(r.path); onClose(); }, [onOpenNote, onClose]); // Render a snippet with the query terms highlighted. The backend's // _make_snippet returns plain text — we match here on the same terms. const highlightSnippet = lsUseCallback((snippet) => { if (!snippet || !query.trim()) return snippet; const terms = query.trim().split(/\s+/).filter(t => t.length > 1).map(t => t.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')); if (!terms.length) return snippet; const re = new RegExp(`(${terms.join('|')})`, 'gi'); const parts = snippet.split(re); return parts.map((p, i) => i % 2 === 1 ? {p} : {p} ); }, [query]); return (
capex, 10-K, NVDA, position sizing, regime