1// Search Chat Module â Ctrl+K command palette for searching conversations 2 3import uiModule from './ui.js'; 4import sessionModule from './sessions.js'; 5 6let API_BASE = ''; 7let debounceTimer = null; 8let selectedIndex = -1; 9let results = []; 10 11function el(id) { return document.getElementById(id); } 12 13export function openSearch() { 14 const overlay = el('search-overlay'); 15 if (!overlay) return; 16 overlay.classList.remove('hidden'); 17 const input = el('search-input'); 18 if (input) { 19 input.value = ''; 20 input.focus(); 21 } 22 selectedIndex = -1; 23 results = []; 24 el('search-results').innerHTML = ''; 25} 26 27export function closeSearch() { 28 const overlay = el('search-overlay'); 29 if (!overlay) return; 30 overlay.classList.add('hidden'); 31 el('search-results').innerHTML = ''; 32 selectedIndex = -1; 33 results = []; 34} 35 36export function isOpen() { 37 const overlay = el('search-overlay'); 38 return overlay && !overlay.classList.contains('hidden'); 39} 40 41var escapeHtml = uiModule.esc; 42 43function highlightMatch(text, query) { 44 if (!query) return escapeHtml(text); 45 const escaped = escapeHtml(text); 46 const regex = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi'); 47 return escaped.replace(regex, '<mark class="search-highlight">$1</mark>'); 48} 49 50function formatTimestamp(iso) { 51 if (!iso) return ''; 52 const d = new Date(iso); 53 const now = new Date(); 54 const diff = now - d; 55 if (diff < 86400000) { 56 return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 57 } 58 if (diff < 604800000) { 59 return d.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' }); 60 } 61 return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }); 62} 63 64function renderResults(data, query) { 65 results = data; 66 selectedIndex = -1; 67 const container = el('search-results'); 68 if (!container) return; 69 70 if (!data || data.length === 0) { 71 container.innerHTML = query 72 ? '<div class="search-empty">No results found</div>' 73 : ''; 74 return; 75 } 76 77 // Group by session 78 const grouped = {}; 79 for (const r of data) { 80 if (!grouped[r.session_id]) { 81 grouped[r.session_id] = { name: r.session_name, items: [] }; 82 } 83 grouped[r.session_id].items.push(r); 84 } 85 86 let html = ''; 87 let idx = 0; 88 for (const [sessionId, group] of Object.entries(grouped)) { 89 html += `<div class="search-group-header">${escapeHtml(group.name)}</div>`; 90 for (const item of group.items) { 91 const roleLabel = item.role === 'user' ? 'You' : 'AI'; 92 html += `<div class="search-result-item" data-index="${idx}" data-session="${escapeHtml(sessionId)}"> 93 <div class="search-result-role">${roleLabel}</div> 94 <div class="search-result-snippet">${highlightMatch(item.content_snippet, query)}</div> 95 <div class="search-result-time">${formatTimestamp(item.timestamp)}</div> 96 </div>`; 97 idx++; 98 } 99 } 100 container.innerHTML = html; 101 102 // Click handlers 103 container.querySelectorAll('.search-result-item').forEach(item => { 104 item.addEventListener('click', () => { 105 const sid = item.dataset.session; 106 navigateToSession(sid); 107 }); 108 }); 109} 110 111function navigateToSession(sessionId) { 112 closeSearch(); 113 if (sessionModule && sessionModule.selectSession) { 114 sessionModule.selectSession(sessionId); 115 } 116} 117 118function updateSelection() { 119 const container = el('search-results'); 120 if (!container) return; 121 const items = container.querySelectorAll('.search-result-item'); 122 items.forEach((item, i) => { 123 item.classList.toggle('selected', i === selectedIndex); 124 }); 125 // Scroll selected into view 126 if (selectedIndex >= 0 && items[selectedIndex]) { 127 items[selectedIndex].scrollIntoView({ block: 'nearest' }); 128 } 129} 130 131function handleKeydown(e) { 132 if (!isOpen()) return; 133 134 const container = el('search-results'); 135 const items = container ? container.querySelectorAll('.search-result-item') : []; 136 const count = items.length; 137 138 if (e.key === 'ArrowDown') { 139 e.preventDefault(); 140 selectedIndex = count > 0 ? Math.min(selectedIndex + 1, count - 1) : -1; 141 updateSelection(); 142 } else if (e.key === 'ArrowUp') { 143 e.preventDefault(); 144 selectedIndex = Math.max(selectedIndex - 1, 0); 145 updateSelection(); 146 } else if (e.key === 'Enter') { 147 e.preventDefault(); 148 if (selectedIndex >= 0 && items[selectedIndex]) { 149 const sid = items[selectedIndex].dataset.session;
150 navigateToSession(sid); 151 } 152 } 153} 154 155function handleInput(e) { 156 const query = e.target.value.trim(); 157 if (debounceTimer) clearTimeout(debounceTimer); 158 159 if (!query) { 160 renderResults([], ''); 161 return; 162 } 163 164 debounceTimer = setTimeout(async () => { 165 try { 166 const res = await fetch(`${API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=20`); 167 if (!res.ok) return; 168 const data = await res.json(); 169 renderResults(data, query); 170 } catch (err) { 171 console.error('Search error:', err); 172 } 173 }, 300); 174} 175 176export function init(apiBase) { 177 API_BASE = apiBase || ''; 178 179 const input = el('search-input'); 180 if (input) { 181 input.addEventListener('input', handleInput); 182 input.addEventListener('keydown', handleKeydown); 183 } 184 185 // Close on overlay click (not popup click) 186 const overlay = el('search-overlay'); 187 if (overlay) { 188 overlay.addEventListener('click', (e) => { 189 if (e.target === overlay) closeSearch(); 190 }); 191 } 192} 193 194const searchChatModule = { 195 init, 196 openSearch, 197 closeSearch, 198 isOpen, 199}; 200 201export default searchChatModule;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.