1// static/js/documentLibrary.js 2/** 3 * Document Library â modal with Chats / Documents / Research / Archive tabs. 4 * Extracted from document.js to reduce file size. 5 */ 6 7import uiModule from './ui.js'; 8import sessionModule from './sessions.js'; 9import spinnerModule from './spinner.js'; 10import markdownModule from './markdown.js'; 11import { makeWindowDraggable } from './windowDrag.js'; 12import { langIcon } from './langIcons.js'; 13import { registerMenuDismiss, dismissOrRemove } from './escMenuStack.js'; 14 15// ââ Injected references from documentModule ââ 16let API_BASE = ''; 17let _esc; // HTML-escape function 18let _getDocs; // () => Map of open docs 19let _isOpenFn; // () => boolean â is doc panel open 20let _createDocument; 21let _loadDocument; 22let _switchToDoc; 23let _openPanel; 24let _addDocToTabs; 25let _syncDocIndicator; 26 27export function initLibrary(config) { 28 API_BASE = config.apiBase; 29 _esc = config.esc; 30 _getDocs = config.getDocs; 31 _isOpenFn = config.isOpen; 32 _createDocument = config.createDocument; 33 _loadDocument = config.loadDocument; 34 _switchToDoc = config.switchToDoc; 35 _openPanel = config.openPanel; 36 _addDocToTabs = config.addDocToTabs; 37 _syncDocIndicator = config.syncDocIndicator; 38} 39 40// ââ Library state ââ 41let _libraryOpen = false; 42// Track which tabs have already played their domino-in cascade so we only 43// animate the *first* time content loads per page session â tab swaps and 44// re-renders after that are instant. 45const _libraryCascadedTabs = new Set(); 46function _maybeCascadeGrid(grid, tabKey) { 47 if (!grid || !tabKey || _libraryCascadedTabs.has(tabKey)) return; 48 _libraryCascadedTabs.add(tabKey); 49 grid.classList.add('doclib-just-opened'); 50 setTimeout(() => grid.classList.remove('doclib-just-opened'), 900); 51} 52let _libraryDocs = []; 53let _libraryTotal = 0; 54let _libraryOffset = 0; 55let _docsVisibleLimit = 20; // chunked reveal (matches the Chats tab's 20) 56let _libraryLanguages = {}; 57let _librarySessionCount = 0; 58let _libraryActiveLanguage = null; 59let _librarySort = 'recent'; 60let _librarySearch = ''; 61let _librarySearchDebounce = null; 62 63// Highlight the active search terms inside a plain string. Escapes first, 64// then wraps each whitespace-separated term in <mark>. Multi-term, matching 65// the backend's per-term search, so every word that matched is marked. 66function _hlSearch(text) { 67 const esc = _esc(text || ''); 68 const q = (_librarySearch || '').trim(); 69 if (!q) return esc; 70 const toks = [...new Set(q.split(/\s+/).filter(Boolean))] 71 .sort((a, b) => b.length - a.length) // prefer longer matches 72 .map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); 73 if (!toks.length) return esc; 74 try { 75 return esc.replace(new RegExp(`(${toks.join('|')})`, 'gi'), 76 '<mark class="doclib-search-hl">$1</mark>'); 77 } catch { return esc; } 78} 79 80function _safeResearchHref(raw) { 81 try { 82 const parsed = new URL(String(raw || '').trim(), window.location.origin); 83 if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href); 84 } catch {} 85 return ''; 86} 87 88let _libraryEscHandler = null; 89let _librarySelectMode = false; 90let _librarySelectedIds = new Set(); 91let _libraryImportMode = false; 92let _libScrollBound = false; // infinite-scroll listener attached once 93let _libraryArchivedView = false; // Documents tab showing archived docs? 94 95// ---- Library animation helpers ---- 96 97 /** Collapse an expanded card */ 98 function _collapseExpandedCard(card) { 99 const grid = card.closest('.doclib-grid'); 100 const instant = card?.dataset?.spaceToggle === '1'; 101 card.classList.remove('doclib-card-expanded'); 102 // Release the height lock so grid returns to natural size 103 if (grid) { 104 grid.style.minHeight = ''; 105 grid.style.maxHeight = ''; 106 } 107 const reader = card.querySelector('.doclib-card-reader'); 108 if (reader) reader.remove(); 109 110 // Fade siblings back in 111 if (grid && !instant) { 112 const siblings = [...grid.querySelectorAll('.doclib-card')].filter(c => c !== card);
113 siblings.forEach(s => { s.style.opacity = '0'; }); 114 requestAnimationFrame(() => { 115 siblings.forEach(s => { 116 s.style.transition = 'opacity 0.15s ease'; 117 s.style.opacity = '1'; 118 }); 119 setTimeout(() => { siblings.forEach(s => { s.style.transition = ''; s.style.opacity = ''; }); }, 200); 120 }); 121 } 122 } 123 124 // Fetch a chat's full history and serialize as plain-text transcript, 125 // then write to the clipboard. Same User: / Assistant: format the chat 126 // header's "Copy Chat" button uses, but works for any session ID â the 127 // library doesn't need the chat to be loaded in the UI first. 128 async function _copyChatById(sessionId) { 129 try { 130 const res = await fetch(`${API_BASE}/api/history/${sessionId}`, { credentials: 'same-origin' }); 131 if (!res.ok) throw new Error(res.statusText); 132 const data = await res.json(); 133 const history = Array.isArray(data) ? data : (data.history || []); 134 const lines = []; 135 for (const m of history) { 136 if (m.role !== 'user' && m.role !== 'assistant') continue; 137 const label = m.role === 'user' ? 'User' : 'Assistant'; 138 const body = (m.content || '') 139 .replace(/<think>[\s\S]*?<\/think>/g, '') 140 .replace(/<think>[\s\S]*$/, '') 141 .trim(); 142 if (body) lines.push(`${label}: ${body}`); 143 } 144 const text = lines.join('\n\n'); 145 if (uiModule && uiModule.copyToClipboard) { 146 await uiModule.copyToClipboard(text); 147 } else { 148 await navigator.clipboard.writeText(text); 149 } 150 } catch (err) { 151 if (uiModule && uiModule.showError) uiModule.showError('Failed to copy chat'); 152 } 153 } 154 155 // Long-press a list card to open its actions menu. `menuSelector` resolves 156 // the existing â¢â¢â¢ button on the card; on hold we trigger its click so the 157 // dropdown opens in its usual spot. Moved finger >10px or release before 158 // 500ms cancels. 159 function _attachLongPressMenu(card, menuSelector) { 160 let hold = null; 161 let start = null; 162 const cancel = () => { if (hold) { clearTimeout(hold); hold = null; } start = null; }; 163 card.addEventListener('pointerdown', (e) => { 164 if (e.target.closest(menuSelector + ', .memory-select-cb, button')) return; 165 start = { x: e.clientX, y: e.clientY }; 166 hold = setTimeout(() => { 167 hold = null; 168 card._suppressNextClick = true; 169 setTimeout(() => { card._suppressNextClick = false; }, 400); 170 if (navigator.vibrate) try { navigator.vibrate(15); } catch {} 171 const btn = card.querySelector(menuSelector); 172 if (btn) btn.click(); 173 }, 500); 174 }); 175 card.addEventListener('pointermove', (e) => { 176 if (!start) return; 177 if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 10) cancel(); 178 }); 179 card.addEventListener('pointerup', cancel); 180 card.addEventListener('pointercancel', cancel); 181 } 182 183 // Inline icons used by the chats/archive/research dropdown rows. Match the 184 // ones used by the documents-tab card menu so the visual language stays 185 // consistent across tabs. 186 const _LIB_DD_ICONS = { 187 open: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>', 188 archive: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>', 189 restore: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9"/><polyline points="3 4 3 9 8 9"/></svg>', 190 delete: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>', 191 clone: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>', 192 copy: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>', 193 }; 194 195 function _showLibDropdown(anchor, items, opts) { 196 opts = opts || {}; 197 document.querySelectorAll('._lib-dd').forEach(dismissOrRemove); 198 const dd = document.createElement('div'); 199 dd.className = 'dropdown session-dropdown-menu _lib-dd'; 200 for (const item of items) {
201 const row = document.createElement('div'); 202 row.className = 'dropdown-item-compact' + (item.danger ? ' dropdown-item-danger' : ''); 203 const iconKey = item.icon || item.label.toLowerCase(); 204 const iconSvg = _LIB_DD_ICONS[iconKey] || ''; 205 row.innerHTML = (iconSvg ? '<span class="dropdown-icon">' + iconSvg + '</span>' : '') + '<span>' + item.label + '</span>'; 206 row.addEventListener('click', (e) => { e.stopPropagation(); teardown(); item.action(); }); 207 dd.appendChild(row); 208 } 209 if (typeof opts.onSelect === 'function') { 210 const sel = document.createElement('div'); 211 sel.className = 'dropdown-item-compact'; 212 sel.innerHTML = 213 '<span class="dropdown-icon"><span style="font-size:16px;line-height:1;position:relative;top:-2px;">â</span></span>' 214 + '<span>Select</span>'; 215 sel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); opts.onSelect(); }); 216 dd.appendChild(sel); 217 } 218 const cancel = document.createElement('div'); 219 cancel.className = 'dropdown-item-compact dropdown-cancel-mobile'; 220 cancel.innerHTML = 221 '<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>' 222 + '<span>Cancel</span>'; 223 cancel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); if (typeof opts.onCancel === 'function') opts.onCancel(); }); 224 dd.appendChild(cancel); 225 document.body.appendChild(dd); 226 const rect = anchor.getBoundingClientRect(); 227 dd.style.right = (window.innerWidth - rect.right) + 'px'; 228 dd.style.top = (rect.bottom + 2) + 'px'; 229 dd.style.display = 'block'; 230 dd.style.zIndex = '100000'; 231 requestAnimationFrame(() => { 232 const mr = dd.getBoundingClientRect(); 233 if (mr.bottom > window.innerHeight - 8) { 234 dd.style.top = (rect.top - mr.height - 2) + 'px'; 235 } 236 if (mr.left < 8) { dd.style.left = '8px'; dd.style.right = 'auto'; } 237 }); 238 // Single idempotent teardown shared by every dismissal path (item click, 239 // outside click, swipe, and the Escape arbiter via registerMenuDismiss). 240 let _unreg = () => {}; 241 const teardown = () => { 242 _unreg(); _unreg = () => {}; 243 document.removeEventListener('click', close); 244 dd.remove(); 245 }; 246 const close = (e) => { if (!dd.contains(e.target)) teardown(); }; 247 setTimeout(() => document.addEventListener('click', close), 0); 248 _unreg = registerMenuDismiss(teardown); 249 dd._dismiss = teardown; // let bulk removers (reopen sweep) tear down cleanly 250 251 // Swipe-down-to-dismiss (mobile). Mirrors the bottom-sheet feel â drag the 252 // popup down and release past the threshold to close. Below threshold, 253 // snap back. Vertical-only; horizontal flicks fall through to scrolling. 254 let _swipeStart = null; 255 let _swipeDy = 0; 256 dd.addEventListener('touchstart', (e) => { 257 if (e.touches.length !== 1) return; 258 _swipeStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }; 259 _swipeDy = 0; 260 dd.style.transition = ''; 261 }, { passive: true }); 262 dd.addEventListener('touchmove', (e) => { 263 if (!_swipeStart || e.touches.length !== 1) return; 264 const dx = e.touches[0].clientX - _swipeStart.x; 265 const dy = e.touches[0].clientY - _swipeStart.y; 266 if (Math.abs(dy) < Math.abs(dx)) { _swipeStart = null; return; } 267 if (dy > 0) { 268 _swipeDy = dy; 269 dd.style.transform = 'translateY(' + dy + 'px)'; 270 dd.style.opacity = String(Math.max(0.3, 1 - dy / 240)); 271 } 272 }, { passive: true }); 273 dd.addEventListener('touchend', () => { 274 if (!_swipeStart) return; 275 _swipeStart = null; 276 if (_swipeDy > 60) { 277 dd.style.transition = 'transform 0.15s ease, opacity 0.15s ease'; 278 dd.style.transform = 'translateY(120px)'; 279 dd.style.opacity = '0'; 280 // Unregister + drop the outside-click listener now; defer the DOM 281 // removal so the slide-out animation can play. 282 _unreg(); _unreg = () => {}; 283 document.removeEventListener('click', close); 284 setTimeout(() => dd.remove(), 160); 285 } else { 286 dd.style.transition = 'transform 0.18s ease, opacity 0.18s ease'; 287 dd.style.transform = ''; 288 dd.style.opacity = ''; 289 } 290 }); 291 } 292 293 // ---- Document Library ---- 294 295 function libraryRelativeTime(isoString) { 296 if (!isoString) return ''; 297 const now = Date.now(); 298 const then = new Date(isoString).getTime(); 299 const diffS = Math.floor((now - then) / 1000); 300 if (diffS < 60) return 'just now'; 301 const diffM = Math.floor(diffS / 60); 302 if (diffM < 60) return diffM + 'm ago'; 303 const diffH = Math.floor(diffM / 60); 304 if (diffH < 24) return diffH + 'h ago'; 305 const diffD = Math.floor(diffH / 24);
306 if (diffD === 1) return 'yesterday'; 307 if (diffD < 14) return diffD + 'd ago'; 308 const diffW = Math.floor(diffD / 7); 309 if (diffW < 8) return diffW + 'w ago'; 310 return new Date(isoString).toLocaleDateString(); 311 } 312 313 async function libraryFetch(append) { 314 if (!append) _libraryOffset = 0; 315 // Bump page size to the backend max (50) so fullscreen doesn't leave 316 // empty space below the loaded rows â same idea as emailLibrary's 317 // limit=100, but documents_library validates `le=50` so we have to 318 // cap at that. Auto-fill loop below picks up any remaining gap. 319 const params = new URLSearchParams({ 320 sort: _librarySort, 321 offset: String(_libraryOffset), 322 limit: '50', 323 }); 324 if (_librarySearch) params.set('search', _librarySearch); 325 if (_libraryActiveLanguage) params.set('language', _libraryActiveLanguage); 326 if (_libraryArchivedView) params.set('archived', 'true'); 327 328 try { 329 const res = await fetch(`${API_BASE}/api/documents/library?${params}`); 330 if (!res.ok) throw new Error(res.statusText); 331 const data = await res.json(); 332 333 if (append) { 334 _libraryDocs = _libraryDocs.concat(data.documents); 335 } else { 336 _libraryDocs = data.documents; 337 _docsVisibleLimit = 20; // reset chunk on a fresh load / search / sort 338 } 339 _libraryTotal = data.total; 340 _libraryLanguages = data.languages; 341 _librarySessionCount = data.session_count; 342 343 libraryRenderStats(); 344 libraryRenderLangChips(); 345 libraryRenderGrid(); 346 libraryRenderLoadMore(); 347 } catch (e) { 348 console.error('Library fetch error:', e); 349 } 350 } 351 352 function libraryRenderStats() { 353 const el = document.getElementById('doclib-stats'); 354 if (!el) return; 355 const totalAll = Object.values(_libraryLanguages).reduce((a, b) => a + b, 0); 356 if (_librarySearch || _libraryActiveLanguage) { 357 el.textContent = `${_libraryTotal} of ${totalAll} document${totalAll !== 1 ? 's' : ''}`; 358 } else { 359 el.textContent = `${totalAll} document${totalAll !== 1 ? 's' : ''}`; 360 } 361 } 362 363 function libraryRenderLangChips() { 364 const wrap = document.getElementById('doclib-chips'); 365 if (!wrap) return; 366 // Remove only language chip buttons, keep sort/select elements 367 wrap.querySelectorAll('.memory-cat-chip').forEach(c => c.remove()); 368 const totalAll = Object.values(_libraryLanguages).reduce((a, b) => a + b, 0); 369 370 // Hide the "all (0)" chip + lang chips entirely when there are no docs. 371 if (totalAll === 0) return; 372 373 const allChip = document.createElement('button'); 374 allChip.className = 'memory-cat-chip' + (!_libraryActiveLanguage ? ' active' : ''); 375 allChip.textContent = `all (${totalAll})`; 376 allChip.addEventListener('click', () => { 377 if (_librarySelectMode) { 378 _libraryDocs.forEach(d => _librarySelectedIds.add(d.id)); 379 libraryUpdateBulkCount(); 380 const selectAllEl = document.getElementById('doclib-select-all'); 381 if (selectAllEl) selectAllEl.checked = true; 382 libraryRenderGrid(); 383 return; 384 } 385 _libraryActiveLanguage = null; 386 libraryFetch(false); 387 }); 388 wrap.appendChild(allChip); 389 390 const sorted = Object.entries(_libraryLanguages).sort((a, b) => b[1] - a[1]); 391 for (const [lang, count] of sorted) { 392 const chip = document.createElement('button'); 393 chip.className = 'memory-cat-chip' + (_libraryActiveLanguage === lang ? ' active' : ''); 394 chip.textContent = `${lang} (${count})`; 395 chip.addEventListener('click', () => { 396 _libraryActiveLanguage = lang; 397 libraryFetch(false); 398 }); 399 wrap.appendChild(chip); 400 } 401 } 402 403 function libraryRemoveDocumentFromState(docId) { 404 const removed = _libraryDocs.find(d => String(d.id) === String(docId)); 405 _libraryDocs = _libraryDocs.filter(d => String(d.id) !== String(docId)); 406 _librarySelectedIds.delete(docId); 407 _libraryTotal = Math.max(0, _libraryTotal - 1); 408 409 const lang = removed && (removed.language || 'text'); 410 if (lang && Object.prototype.hasOwnProperty.call(_libraryLanguages, lang)) { 411 const next = Math.max(0, Number(_libraryLanguages[lang] || 0) - 1); 412 if (next > 0) { 413 _libraryLanguages[lang] = next; 414 } else { 415 delete _libraryLanguages[lang]; 416 } 417 } 418 419 libraryRenderStats(); 420 libraryRenderLangChips(); 421 libraryUpdateBulkCount(); 422 } 423 424 function libraryRenderGrid() { 425 const grid = document.getElementById('doclib-grid'); 426 if (!grid) return; 427 // An open card menu is mounted on <body> (to escape overflow clipping), so 428 // clearing the grid would orphan it; dismiss it first so its listener + 429 // Escape-stack entry go too. 430 document.querySelectorAll('.doclib-card-dropdown').forEach(dismissOrRemove); 431 grid.innerHTML = ''; 432 // Drop any previous inline load-more â regenerated below alongside the list. 433 if (grid.parentElement) grid.parentElement.querySelectorAll(':scope > .doclib-inline-load-more').forEach(b => b.remove()); 434 435 if (_libraryDocs.length === 0) { 436 if (_librarySearch || _libraryActiveLanguage) { 437 grid.innerHTML = '<div class="doclib-empty">No documents match your search.</div>'; 438 } else { 439 const _impIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin:0 4px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>'; 440 grid.innerHTML = 441 '<div class="doclib-empty" style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;">' + 442 '<span>No documents yet</span>' + 443 '<span style="opacity:0.7;font-size:11px;">' + 444 '<a href="#" data-doclib-import style="color:var(--accent,var(--red));text-decoration:underline;">Import' + _impIco + '</a>' + 445 ' · or create one in a session' + 446 '</span>' + 447 '</div>'; 448 grid.querySelector('[data-doclib-import]')?.addEventListener('click', (e) => { 449 e.preventDefault(); 450 document.getElementById('doclib-import-file-btn')?.click(); 451 }); 452 } 453 return; 454 } 455 _maybeCascadeGrid(grid, 'documents'); 456 457 // Reveal in 20-at-a-time chunks (matches the Chats tab). The legacy 458 // server-pagination button is suppressed in libraryRenderLoadMore; this 459 // inline button is the single control. 460 const shown = _libraryDocs.slice(0, _docsVisibleLimit); 461 for (const doc of shown) { 462 grid.appendChild(libraryCreateCard(doc)); 463 }
464 // Show a "Load more" while either more loaded docs remain to reveal, or 465 // more exist on the server beyond what we've fetched. 466 const shownCount = shown.length; 467 if (shownCount < _libraryTotal) { 468 const btn = document.createElement('button'); 469 btn.className = 'doclib-load-more doclib-inline-load-more'; 470 btn.id = 'doclib-docs-load-more'; 471 btn.textContent = `Load more (${shownCount} of ${_libraryTotal})`; 472 btn.addEventListener('click', async () => { 473 _docsVisibleLimit += 20; 474 // Need more than we've fetched? pull the next server page first. 475 if (_docsVisibleLimit > _libraryDocs.length && _libraryDocs.length < _libraryTotal) { 476 _libraryOffset = _libraryDocs.length; 477 await libraryFetch(true); // appends + re-renders 478 } else { 479 libraryRenderGrid(); 480 } 481 }); 482 grid.parentElement.appendChild(btn); 483 } 484 } 485 486 // Infinite scroll for the library (mobile + desktop), covering EVERY tab â 487 // Documents, Chats, Research, Archive all render a `.doclib-inline-load-more` 488 // button (regenerated fresh each render). A capture-phase scroll listener 489 // catches whichever element actually scrolls and, when the visible button 490 // nears the viewport bottom, clicks it â reusing each tab's own load logic. 491 // We mark a button once clicked so the SAME instance can't double-fire (the 492 // next render makes a fresh, unmarked one), which is safe for both the sync 493 // reveal tabs (Chats/Research) and the async fetch tabs (Documents/Archive). 494 if (!_libScrollBound) { 495 _libScrollBound = true; 496 let _tick = false; 497 const _maybeAutoLoad = () => { 498 _tick = false; 499 if (!_libraryOpen) return; 500 for (const btn of document.querySelectorAll('.doclib-inline-load-more')) { 501 if (btn.dataset.autoLoaded) continue; 502 if (!btn.offsetParent) continue; // inactive tab (hidden) 503 if (btn.getBoundingClientRect().top > window.innerHeight + 600) continue; 504 btn.dataset.autoLoaded = '1'; 505 btn.click(); 506 break; // one load per scroll tick 507 } 508 }; 509 document.addEventListener('scroll', () => { 510 if (_tick) return; 511 _tick = true; 512 requestAnimationFrame(_maybeAutoLoad); 513 }, true); 514 } 515 516 function libraryCreateCard(doc) { 517 const card = document.createElement('div'); 518 card.className = 'doclib-card memory-item'; 519 card.dataset.docId = doc.id; 520 if (_librarySelectMode && _librarySelectedIds.has(doc.id)) { 521 card.classList.add('selected'); 522 } 523 524 // Checkbox for select mode 525 if (_librarySelectMode) { 526 const cb = document.createElement('input'); 527 cb.type = 'checkbox'; 528 cb.className = 'memory-select-cb'; 529 cb.checked = _librarySelectedIds.has(doc.id); 530 cb.addEventListener('click', (e) => e.stopPropagation()); 531 cb.addEventListener('change', () => { 532 libraryToggleSelectItem(doc.id); 533 card.classList.toggle('selected', _librarySelectedIds.has(doc.id)); 534 const selectAllEl = document.getElementById('doclib-select-all'); 535 if (selectAllEl) selectAllEl.checked = _libraryDocs.every(d => _librarySelectedIds.has(d.id)); 536 }); 537 card.appendChild(cb); 538 } 539 540 // Content wrapper 541 const content = document.createElement('div'); 542 content.style.cssText = 'flex:1;min-width:0;padding-top:4px;'; 543 544 // Title row with version badge 545 const titleRow = document.createElement('div'); 546 titleRow.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%;'; 547 const titleEl = document.createElement('span'); 548 titleEl.className = 'memory-item-title'; 549 titleEl.style.cssText = 'flex:0 1 auto;min-width:0;'; 550 // Language-specific icon next to the title (matches the document's type: 551 // markdown/csv/python/html/etc.). Falls back to the generic document icon 552 // when the language has no dedicated glyph. 553 const _GEN_DOC_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.4;flex-shrink:0;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'; 554 const _langSvg = doc.language && doc.language !== 'text' 555 ? langIcon(doc.language, 12, { style: 'vertical-align:-2px;margin-right:4px;opacity:0.55;flex-shrink:0;color:currentColor;' }) 556 : ''; 557 titleEl.innerHTML = (_langSvg || _GEN_DOC_ICON) + _hlSearch(doc.title || 'Untitled'); 558 titleRow.appendChild(titleEl); 559 const verBadge = document.createElement('span'); 560 verBadge.style.cssText = 'font-size:9px;padding:1px 6px;border-radius:8px;background:color-mix(in srgb, var(--red) 15%, transparent);border:1px solid color-mix(in srgb, var(--red) 40%, transparent);color:var(--red);flex-shrink:0;'; 561 verBadge.textContent = 'v' + (doc.version_count || 1); 562 titleRow.appendChild(verBadge); 563 // Chevron pushed to the right end of the title row â collapsed 564 // shows nothing, expanded reveals a downward chevron so the user 565 // sees the card is open and can tap to close it. 566 const chevron = document.createElement('span'); 567 chevron.className = 'doclib-card-chevron'; 568 chevron.style.marginLeft = 'auto'; 569 chevron.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>'; 570 titleRow.appendChild(chevron); 571 content.appendChild(titleRow); 572 573 // Meta line: session â [lang-icon language] â time 574 const meta = document.createElement('div'); 575 meta.className = 'memory-item-meta'; 576 meta.style.cssText = 'font-size:10px;opacity:0.55;margin-top:2px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;'; 577 const _esc = (s) => uiModule.esc(String(s || '')); 578 const pieces = []; 579 if (doc.session_name) pieces.push(`<span>${_esc(doc.session_name)}</span>`); 580 if (doc.language && doc.language !== 'text') { 581 const ic = langIcon(doc.language, 11, { style: 'vertical-align:-2px;flex-shrink:0;
581opacity:0.65;color:currentColor;' }); 582 pieces.push(`<span style="display:inline-flex;align-items:center;gap:3px;">${ic}${_esc(doc.language)}</span>`); 583 } 584 pieces.push(`<span>${_esc(libraryRelativeTime(doc.updated_at))}</span>`); 585 meta.innerHTML = pieces.join('<span style="opacity:0.5;">\u00b7</span>'); 586 // Strip the per-language icon from the meta line \u2014 it now sits next to the 587 // title above, so duplicating it here was redundant. 588 content.appendChild(meta); 589 card.appendChild(content); 590 591 // Header element (kept for expand/preview compatibility) 592 const header = document.createElement('div'); 593 header.className = 'doclib-card-header'; 594 header.style.display = 'none'; 595 596 // Action buttons â "..." menu 597 const actionsWrap = document.createElement('div'); 598 actionsWrap.className = 'memory-item-actions'; 599 const menuWrap = document.createElement('span'); 600 menuWrap.className = 'doclib-card-menu-wrap'; 601 menuWrap.style.position = 'relative'; 602 const menuBtn = document.createElement('button'); 603 menuBtn.className = 'memory-item-btn'; 604 menuBtn.title = 'Actions'; 605 menuBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>'; 606 menuBtn.addEventListener('click', (e) => { 607 e.stopPropagation(); 608 // Mobile: the custom 5-item dropdown is too crowded â route through the 609 // shared _showLibDropdown with a small set (Open, Clone) plus Select + 610 // Cancel. Heavier actions (Archive, Delete, Export) live in bulk mode. 611 if (window.innerWidth <= 768) { 612 const items = []; 613 if (doc.session_id) items.push({ label: 'Open', action: () => libraryOpenInSession(doc) }); 614 items.push({ label: 'Clone', action: () => libraryImportDocument(doc) }); 615 _showLibDropdown(menuBtn, items, { onSelect: () => { 616 libraryEnterSelectMode(); 617 _librarySelectedIds.add(doc.id); 618 libraryUpdateBulkCount(); 619 libraryRenderGrid(); 620 } }); 621 return; 622 } 623 const dropdown = menuWrap.querySelector('.doclib-card-dropdown') || document.body.querySelector('.doclib-card-dropdown[data-owner="' + CSS.escape(doc.id) + '"]'); 624 if (dropdown) { 625 const isOpen = dropdown.style.display !== 'none' && dropdown.parentElement === document.body; 626 if (isOpen) { 627 hideCardDropdown(); 628 } else { 629 // Position fixed on body to escape overflow clipping 630 const rect = menuBtn.getBoundingClientRect(); 631 document.body.appendChild(dropdown); 632 dropdown.dataset.owner = doc.id; 633 dropdown.style.cssText = 'position:fixed;z-index:10000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;'; 634 dropdown.style.top = (rect.bottom + 4) + 'px'; 635 dropdown.style.left = 'auto'; 636 dropdown.style.right = (window.innerWidth - rect.right) + 'px'; 637 // Clamp to viewport 638 requestAnimationFrame(() => { 639 const mr = dropdown.getBoundingClientRect(); 640 if (mr.bottom > window.innerHeight - 8) dropdown.style.top = (rect.top - mr.height - 4) + 'px'; 641 if (mr.left < 8) { dropdown.style.left = '8px'; dropdown.style.right = 'auto'; } 642 }); 643 // Close on outside click or Escape (the latter via the registry). 644 _cardDocClick = (ev) => { 645 if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) hideCardDropdown(); 646 }; 647 setTimeout(() => document.addEventListener('click', _cardDocClick, true), 0); 648 _cardUnreg = registerMenuDismiss(hideCardDropdown); 649 } 650 } 651 }); 652 menuWrap.appendChild(menuBtn); 653 654 // Dropdown menu 655 const dropdown = document.createElement('div'); 656 dropdown.className = 'doclib-card-dropdown'; 657 dropdown.style.cssText = 'display:none;position:absolute;top:100%;right:0;z-index:1000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;'; 658 659 // Single close path for the card action dropdown, shared by the toggle 660 // button, the outside-click listener, every menu item, and the Escape 661 // arbiter (via registerMenuDismiss). Hides the menu, returns it to its 662 // wrapper, drops the outside-click listener, and unregisters from the 663 // Escape stack. Idempotent â safe to call from whichever path fires first. 664 let _cardUnreg = () => {}; 665 let _cardDocClick = null; 666 function hideCardDropdown() { 667 _cardUnreg(); _cardUnreg = () => {}; 668 if (_cardDocClick) { document.removeEventListener('click', _cardDocClick, true); _cardDocClick = null; } 669 dropdown.style.display = 'none'; 670 if (dropdown.parentElement === document.body) menuWrap.appendChild(dropdown); 671 } 672 dropdown._dismiss = hideCardDropdown; // bulk removers tear down through this 673 674 const _di = (svg) => `<span class="dropdown-icon">${svg}</span>`;
675 const _openIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>'; 676 677 // Open 678 const openItem = document.createElement('button'); 679 openItem.className = 'dropdown-item-compact'; 680 openItem.style.cssText = 'background:none;border:none;width:100%;'; 681 openItem.innerHTML = _di(_openIco) + '<span>Open</span>'; 682 if (doc.session_id) { 683 openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenInSession(doc); }); 684 } else { 685 // Orphaned doc (closed / session detached) is still openable in the editor 686 // by id â libraryOpenDocument handles the no-session case (#1602). 687 openItem.title = 'Open in the editor'; 688 openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenDocument(doc); }); 689 } 690 dropdown.appendChild(openItem); 691 692 // Clone 693 const _cloneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; 694 const cloneItem = document.createElement('button'); 695 cloneItem.className = 'dropdown-item-compact'; 696 cloneItem.style.cssText = 'background:none;border:none;width:100%;'; 697 cloneItem.innerHTML = _di(_cloneIco) + '<span>Clone</span>'; 698 cloneItem.title = 'Clone to active session'; 699 cloneItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryImportDocument(doc); }); 700 dropdown.appendChild(cloneItem); 701 702 // Export 703 const _exportIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>'; 704 const exportItem = document.createElement('button'); 705 exportItem.className = 'dropdown-item-compact'; 706 exportItem.style.cssText = 'background:none;border:none;width:100%;'; 707 exportItem.innerHTML = _di(_exportIco) + '<span>Export</span>'; 708 exportItem.addEventListener('click', async (e) => { 709 e.stopPropagation(); 710 hideCardDropdown(); 711 try { 712 const res = await fetch(`${API_BASE}/api/document/${doc.id}`); 713 if (!res.ok) throw new Error('Failed'); 714 const full = await res.json(); 715 const extMap = { javascript: '.js', python: '.py', html: '.html', css: '.css', markdown: '.md', json: '.json', yaml: '.yml', bash: '.sh', sql: '.sql', rust: '.rs', go: '.go', java: '.java', c: '.c', cpp: '.cpp', typescript: '.ts', ruby: '.rb', php: '.php', xml: '.xml', toml: '.toml', ini: '.ini' }; 716 const ext = extMap[full.language] || '.txt'; 717 const blob = new Blob([full.current_content || ''], { type: 'text/plain' }); 718 const a = document.createElement('a'); 719 a.href = URL.createObjectURL(blob); 720 a.download = (full.title || 'document') + ext; 721 a.click(); 722 URL.revokeObjectURL(a.href); 723 } catch { if (uiModule) uiModule.showError('Failed to export document'); } 724 }); 725 dropdown.appendChild(exportItem); 726 727 // Archive / Restore â soft-archive a doc out of the main list, or bring it back. 728 const _archiveIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>'; 729 const archiveItem = document.createElement('button'); 730 archiveItem.className = 'dropdown-item-compact'; 731 archiveItem.style.cssText = 'background:none;border:none;width:100%;'; 732 archiveItem.innerHTML = _di(_archiveIco) + `<span>${_libraryArchivedView ? 'Restore' : 'Archive'}</span>`;
733 archiveItem.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)'; 734 archiveItem.addEventListener('click', async (e) => { 735 e.stopPropagation(); 736 hideCardDropdown(); 737 const toArchived = !_libraryArchivedView; 738 try { 739 const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' }); 740 if (!res.ok) throw new Error('failed'); 741 // Drop it from the current view (it no longer belongs here) and refresh. 742 libraryRemoveDocumentFromState(doc.id); 743 libraryRenderGrid(); 744 if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored'); 745 } catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); } 746 }); 747 dropdown.appendChild(archiveItem); 748 749 // Delete 750 const _deleteIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>'; 751 const deleteItem = document.createElement('button'); 752 deleteItem.className = 'dropdown-item-compact dropdown-item-danger'; 753 deleteItem.style.cssText = 'background:none;border:none;width:100%;'; 754 deleteItem.innerHTML = _di(_deleteIco) + '<span>Delete</span>'; 755 deleteItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryDeleteSingle(doc.id, card); }); 756 dropdown.appendChild(deleteItem); 757 758 menuWrap.appendChild(dropdown); 759 actionsWrap.appendChild(menuWrap); 760 card.appendChild(actionsWrap); 761 762 // Hidden header for expand/preview compatibility 763 card.appendChild(header); 764 765 // Inject library card hover styles once 766 if (!document.getElementById('doclib-card-styles')) { 767 const s = document.createElement('style'); 768 s.id = 'doclib-card-styles'; 769 s.textContent = `.doclib-card:hover .doclib-card-icon-btn{opacity:.4}.doclib-card-icon-btn:hover{opacity:1!important}.doclib-card-text-btn{background:none;border:1px solid var(--border);color:var(--fg-muted);font-size:10px;padding:3px 8px;border-radius:4px;cursor:pointer;transition:border-color .15s,color .15s}.doclib-card-text-btn:hover{border-color:var(--accent,var(--red));color:var(--accent,var(--red))}.doclib-card-text-btn-danger{border-color:var(--color-danger,#e06c75)!important;color:var(--color-danger,#e06c75)!important}.doclib-card-text-btn-danger:hover{border-color:#ff4d4d!important;color:#ff4d4d!important}.doclib-card-chevron{display:none;align-items:center;justify-content:center;align-self:center;opacity:0.6;transition:transform .15s ease;flex-shrink:0;height:14px;line-height:0}.doclib-card-expanded .doclib-card-chevron{display:inline-flex;transform:rotate(180deg)}.doclib-card-chevron svg{display:block}`; 770 document.head.appendChild(s); 771 } 772 773 // Preview â hidden by default, shown on expand 774 const preview = document.createElement('div'); 775 preview.className = 'doclib-card-preview'; 776 const pre = document.createElement('pre'); 777 const code = document.createElement('code'); 778 try { 779 if (doc.language && doc.language !== 'text' && window.hljs && !_librarySearch) { 780 code.innerHTML = window.hljs.highlight(doc.preview || '', { language: doc.language }).value; 781 } else if (_librarySearch) { 782 // While searching, highlight matched terms in the preview (plain 783 // text) rather than syntax-highlighting â the match is what matters. 784 code.innerHTML = _hlSearch(doc.preview || ''); 785 } else { 786 code.textContent = doc.preview || ''; 787 } 788 } catch { 789 code.textContent = doc.preview || ''; 790 } 791 pre.appendChild(code); 792 preview.appendChild(pre); 793 794 // Expanded-only action bar â inside preview 795 const expandedActions = document.createElement('div'); 796 expandedActions.className = 'doclib-card-expanded-actions'; 797 798 const openBtn = document.createElement('button'); 799 openBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
800 openBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M5 12h14M13 5l7 7-7 7"/></svg>Open'; 801 if (doc.session_id) { 802 openBtn.title = 'Open in original session'; 803 openBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryOpenInSession(doc); }); 804 } else { 805 // Orphaned doc (closed / session detached) is still openable in the editor 806 // by id â libraryOpenDocument handles the no-session case (#1602). 807 openBtn.title = 'Open in the editor'; 808 openBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryOpenDocument(doc); }); 809 } 810 811 const cloneBtn = document.createElement('button'); 812 cloneBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; 813 cloneBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Clone'; 814 cloneBtn.title = 'Clone â copy to active session'; 815 cloneBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryImportDocument(doc); }); 816 817 const deleteBtn = document.createElement('button'); 818 deleteBtn.className = 'doclib-card-text-btn doclib-card-action-btn doclib-card-text-btn-danger';
819 deleteBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete'; 820 deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryDeleteSingle(doc.id, card); }); 821 822 // Archive sits next to Delete on the LEFT â same lineup as the chat 823 // and research footers. Label flips to Restore inside the Archive view. 824 const archiveBtn = document.createElement('button'); 825 archiveBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; 826 archiveBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>' + (_libraryArchivedView ? 'Restore' : 'Archive'); 827 archiveBtn.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)'; 828 archiveBtn.addEventListener('click', async (e) => { 829 e.stopPropagation(); 830 const toArchived = !_libraryArchivedView; 831 try { 832 const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' }); 833 if (!res.ok) throw new Error('failed'); 834 libraryRemoveDocumentFromState(doc.id); 835 libraryRenderGrid(); 836 if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored'); 837 } catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); } 838 }); 839 840 const leftGroup = document.createElement('div'); 841 leftGroup.className = 'doclib-action-group'; 842 const btnRow = document.createElement('div'); 843 btnRow.className = 'doclib-action-btn-row'; 844 // Export lives in the â® menu â keep the footer uncrowded with Clone + Open. 845 btnRow.appendChild(cloneBtn); 846 btnRow.appendChild(openBtn); 847 leftGroup.appendChild(btnRow); 848 // Delete furthest LEFT, then Archive; Open/Clone group on the RIGHT. 849 // Nudge the Delete/Archive pair 8px left for alignment. 850 deleteBtn.style.cssText += ';position:relative;left:-8px;'; 851 archiveBtn.style.cssText += ';position:relative;left:-8px;'; 852 expandedActions.appendChild(deleteBtn); 853 expandedActions.appendChild(archiveBtn); 854 expandedActions.appendChild(leftGroup); 855 856 preview.appendChild(expandedActions); 857 card.appendChild(preview); 858 859 card.addEventListener('click', () => { 860 if (card._suppressNextClick) { card._suppressNextClick = false; return; } 861 if (_librarySelectMode) { 862 const cb = card.querySelector('.memory-select-cb'); 863 if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); } 864 } else { 865 libraryExpandCard(card, doc); 866 } 867 }); 868 _attachLongPressMenu(card, '.memory-item-btn'); 869 return card; 870 } 871 872 async function libraryExpandCard(card, doc) { 873 const grid = card.closest('.doclib-grid'); 874 const instant = card?.dataset?.spaceToggle === '1'; 875 876 // Already expanded â collapse 877 if (card.classList.contains('doclib-card-expanded')) { 878 _collapseExpandedCard(card); 879 return; 880 } 881 882 // Collapse any other expanded card 883 if (grid) { 884 grid.querySelectorAll('.doclib-card-expanded').forEach(c => _collapseExpandedCard(c)); 885 } 886 887 // Fade siblings out before the CSS display:none kicks in 888 const siblings = grid ? [...grid.querySelectorAll('.doclib-card')].filter(c => c !== card) : []; 889 // Force explicit starting opacity so the first transition works 890 siblings.forEach(s => { s.style.opacity = '1'; }); 891 // Force reflow so the browser registers the starting value 892 if (!instant) { 893 if (siblings.length) siblings[0].offsetHeight;
894 siblings.forEach(s => { s.style.transition = 'opacity 0.12s ease'; s.style.opacity = '0'; }); 895 } 896 897 // Capture the full grid + toolbar height so the modal stays the same 898 // size on desktop. On mobile the modal is full-height and we want the 899 // grid to claim all available space â skip the lock there. 900 const isMobile = window.innerWidth <= 768; 901 const toolbar = grid ? grid.closest('.admin-card')?.querySelector('.memory-toolbar') : null; 902 const toolbarH = toolbar ? toolbar.offsetHeight : 0; 903 if (grid && !isMobile) { 904 grid.style.minHeight = (grid.offsetHeight + toolbarH) + 'px'; 905 grid.style.maxHeight = (grid.offsetHeight + toolbarH) + 'px'; 906 } 907 908 // Wait for fade-out, then expand 909 if (!instant) await new Promise(r => setTimeout(r, 120)); 910 911 card.classList.add('doclib-card-expanded'); 912 if (grid) grid.scrollTop = 0; 913 914 // Clean up sibling inline styles (CSS display:none takes over now) 915 siblings.forEach(s => { s.style.transition = ''; s.style.opacity = ''; }); 916 917 // Load full content into preview area 918 const preview = card.querySelector('.doclib-card-preview'); 919 if (!preview) return; 920 921 const actionsBar = preview.querySelector('.doclib-card-expanded-actions'); 922 const existingPre = preview.querySelector('pre'); 923 924 try { 925 const res = await fetch(`${API_BASE}/api/document/${doc.id}`); 926 if (!res.ok) throw new Error('Failed'); 927 const full = await res.json(); 928 const content = full.current_content || ''; 929 const lang = full.language || doc.language || 'text'; 930 931 // PDF-backed docs have a marker comment in their markdown â show the 932 // rendered PDF in an iframe instead of dumping markdown source. 933 const isPdfDoc = /<!--\s*pdf_(?:form_)?source\s+upload_id="[^"]+"/.test(content); 934 const existingFrame = preview.querySelector('.doclib-card-pdf-frame'); 935 936 if (isPdfDoc) { 937 const frame = document.createElement('iframe'); 938 frame.className = 'doclib-card-pdf-frame'; 939 frame.src = `${API_BASE}/api/document/${doc.id}/render-pdf?t=${Date.now()}`; 940 frame.style.cssText = 'width:100%;height:60vh;border:1px solid var(--border);border-radius:6px;background:var(--bg);opacity:0;transition:opacity 0.15s ease;'; 941 if (existingPre) existingPre.remove(); 942 if (existingFrame) existingFrame.remove(); 943 preview.insertBefore(frame, preview.firstChild); 944 if (actionsBar && !preview.contains(actionsBar)) preview.appendChild(actionsBar); 945 requestAnimationFrame(() => { frame.style.opacity = '1'; }); 946 return; 947 } 948 949 const pre = document.createElement('pre'); 950 const code = document.createElement('code'); 951 // Syntax highlighting is synchronous and O(n) â running it over a whole 952 // large document froze the main thread on click (the "lag"). Only 953 // highlight up to a cap; bigger docs render as plain text (still fully 954 // shown) so the preview opens instantly. Markdown gains little from 955 // highlighting anyway, so skip it there. 956 const HL_CAP = 20000;
957 try { 958 if (lang && lang !== 'text' && lang !== 'markdown' && window.hljs && content.length <= HL_CAP) { 959 code.innerHTML = window.hljs.highlight(content, { language: lang }).value; 960 } else { 961 code.textContent = content; 962 } 963 } catch { 964 code.textContent = content; 965 } 966 pre.appendChild(code); 967 968 // Swap content â fade in the full version 969 if (existingPre) existingPre.remove(); 970 if (existingFrame) existingFrame.remove(); 971 pre.style.opacity = '0'; 972 preview.insertBefore(pre, preview.firstChild); 973 if (actionsBar && !preview.contains(actionsBar)) preview.appendChild(actionsBar); 974 requestAnimationFrame(() => { 975 pre.style.transition = 'opacity 0.15s ease'; 976 pre.style.opacity = '1'; 977 }); 978 } catch (e) { 979 // On error, keep existing preview if available 980 if (!existingPre) { 981 preview.innerHTML = '<div style="padding:8px;color:var(--color-error);font-size:10px;">Failed to load</div>'; 982 } 983 if (actionsBar && !preview.contains(actionsBar)) preview.appendChild(actionsBar); 984 } 985 } 986 987 function libraryRenderLoadMore() { 988 // Documents now reveal in 20-at-a-time chunks via the inline "Load more" 989 // rendered inside libraryRenderGrid (matching the Chats tab). The legacy 990 // server-pagination button + auto-fill are retired to avoid a double 991 // control and surprise auto-loading. 992 const legacy = document.getElementById('doclib-load-more'); 993 if (legacy) legacy.style.display = 'none'; 994 } 995 996 async function libraryOpenDocument(doc) { 997 closeLibrary(); 998 // Orphaned doc (session deleted) â just open in editor without switching session 999 if (!doc.session_id) { 1000 _loadDocument(doc.id); 1001 return; 1002 } 1003 const currentSessionId = sessionModule && sessionModule.getCurrentSessionId(); 1004 if (doc.session_id !== currentSessionId) { 1005 await sessionModule.selectSession(doc.session_id); 1006 } 1007 _loadDocument(doc.id); 1008 } 1009 1010 /** Open a document in its linked session */ 1011 async function libraryOpenInSession(doc) { 1012 if (!doc.session_id) return; 1013 closeLibrary(); 1014 1015 // Step 1: switch session if needed and wait for it to load 1016 const currentSessionId = sessionModule && sessionModule.getCurrentSessionId(); 1017 if (doc.session_id !== currentSessionId) { 1018 await sessionModule.selectSession(doc.session_id); 1019 // Give the session UI a moment to settle 1020 await new Promise(r => setTimeout(r, 150)); 1021 } 1022 1023 // Step 2: ensure doc is in tabs 1024 const docs = _getDocs(); 1025 if (!docs.has(doc.id)) { 1026 const res = await fetch(`${API_BASE}/api/document/${doc.id}`); 1027 if (res.ok) { 1028 const full = await res.json(); 1029 _addDocToTabs(full, doc.session_id); 1030 } 1031 } 1032 1033 // Step 3: open panel (slide-in is handled by openPanel) 1034 if (!_isOpenFn()) _openPanel(); 1035 1036 _switchToDoc(doc.id); 1037 _syncDocIndicator(); 1038 } 1039 1040 /** Copy a document from the library into the current session */ 1041 async function libraryImportDocument(doc) { 1042 let sessionId = sessionModule && sessionModule.getCurrentSessionId(); 1043 if (!sessionId) { 1044 // Create a new session if none exists 1045 if (sessionModule && sessionModule.hasPendingChat && sessionModule.hasPendingChat()) { 1046 const ok = await sessionModule.materializePendingSession(); 1047 if (ok) sessionId = sessionModule.getCurrentSessionId(); 1048 } 1049 if (!sessionId) { 1050 // No pending chat either â trigger new session, preserving the current model 1051 const curModel = sessionModule.getCurrentModel ? sessionModule.getCurrentModel() : null; 1052 const sessions = sessionModule ? sessionModule.getSessions() : []; 1053 // Prefer the session matching the current model, otherwise fall back to first with a model 1054 const withModel = sessions.filter(s => s.endpoint_url && s.model); 1055 const match = (curModel && withModel.find(s => s.model === curModel)) || withModel[0]; 1056 if (match) { 1057 sessionModule.createDirectChat(match.endpoint_url, match.model, match.endpoint_id); 1058 const ok = await sessionModule.materializePendingSession(); 1059 if (ok) sessionId = sessionModule.getCurrentSessionId(); 1060 } 1061 } 1062 if (!sessionId) { 1063 if (uiModule) uiModule.showError('Could not create a session'); 1064 return; 1065 } 1066 } 1067 try { 1068 // Fetch full content of the source document 1069 const srcRes = await fetch(`${API_BASE}/api/document/${doc.id}`); 1070 if (!srcRes.ok) throw new Error('Failed to fetch document'); 1071 const src = await srcRes.json(); 1072 1073 // Deduplicate title â append (2), (3), etc. if name already exists in session 1074 let baseTitle = src.title || doc.title || 'Untitled'; 1075 const existingTitles = new Set(); 1076 const docs = _getDocs(); 1077 for (const [, d] of docs) { 1078 if (d.sessionId === sessionId && d.title) existingTitles.add(d.title); 1079 } 1080 if (existingTitles.has(baseTitle)) { 1081 // Strip existing (N) suffix to get the root name 1082 const root = baseTitle.replace(/\s*\(\d+\)$/, ''); 1083 let n = 2; 1084 while (existingTitles.has(root + ' (' + n + ')')) n++; 1085 baseTitle = root + ' (' + n + ')'; 1086 } 1087 1088 // Create a new document copy in the current session 1089 const res = await fetch(`${API_BASE}/api/document`, { 1090 method: 'POST', 1091 credentials: 'same-origin', 1092 headers: { 'Content-Type': 'application/json' }, 1093 body: JSON.stringify({ 1094 session_id: sessionId, 1095 // Preserve the source's type; default to markdown when unknown 1096 // (the backend also sniffs, but this keeps the tab label correct). 1097 language: src.language || doc.language || 'markdown', 1098 content: src.current_content || '', 1099 }), 1100 }); 1101 if (!res.ok) throw new Error('Failed to create document'); 1102 const created = await res.json(); 1103 closeLibrary(); 1104 _addDocToTabs(created, sessionId);
1105 if (!_isOpenFn()) _openPanel(); 1106 1107 _switchToDoc(created.id); 1108 _syncDocIndicator(); 1109 if (uiModule) uiModule.showToast('Document cloned to session'); 1110 } catch (e) { 1111 console.error('Failed to import document:', e); 1112 if (uiModule) uiModule.showError('Failed to import document'); 1113 } 1114 } 1115 1116 // ---- Library bulk operations ---- 1117 1118 function libraryEnterSelectMode() { 1119 _librarySelectMode = true; 1120 _librarySelectedIds.clear(); 1121 const bulkBar = document.getElementById('doclib-bulk-bar'); 1122 const selectBtn = document.getElementById('doclib-select-btn'); 1123 if (bulkBar) bulkBar.classList.remove('hidden'); 1124 if (selectBtn) { selectBtn.classList.add('active'); selectBtn.textContent = 'Cancel'; } 1125 libraryUpdateBulkCount(); 1126 libraryRenderGrid(); 1127 } 1128 1129 function libraryExitSelectMode() { 1130 _librarySelectMode = false; 1131 _librarySelectedIds.clear(); 1132 const bulkBar = document.getElementById('doclib-bulk-bar'); 1133 const selectBtn = document.getElementById('doclib-select-btn'); 1134 const selectAll = document.getElementById('doclib-select-all'); 1135 if (bulkBar) bulkBar.classList.add('hidden'); 1136 if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.textContent = 'Select'; } 1137 if (selectAll) selectAll.checked = false; 1138 libraryRenderGrid(); 1139 } 1140 1141 function libraryToggleSelectItem(id) { 1142 if (_librarySelectedIds.has(id)) { 1143 _librarySelectedIds.delete(id); 1144 } else { 1145 _librarySelectedIds.add(id); 1146 } 1147 libraryUpdateBulkCount(); 1148 } 1149 1150 function libraryToggleSelectAll() { 1151 const selectAllEl = document.getElementById('doclib-select-all'); 1152 if (!selectAllEl) return; 1153 if (selectAllEl.checked) { 1154 _libraryDocs.forEach(d => _librarySelectedIds.add(d.id)); 1155 } else { 1156 _librarySelectedIds.clear(); 1157 } 1158 libraryUpdateBulkCount(); 1159 libraryRenderGrid(); 1160 } 1161 1162 function libraryUpdateBulkCount() { 1163 const countEl = document.getElementById('doclib-selected-count'); 1164 const actionsBtn = document.getElementById('doclib-bulk-actions'); 1165 if (countEl) countEl.textContent = `${_librarySelectedIds.size} Selected`; 1166 if (actionsBtn) actionsBtn.style.color = _librarySelectedIds.size > 0 ? 'var(--fg)' : ''; 1167 // Legacy per-action buttons no longer rendered â guard so the rest of the 1168 // function (if anything still references them) doesn't crash. 1169 const deleteBtn = document.getElementById('doclib-bulk-delete'); 1170 const exportBtn = document.getElementById('doclib-bulk-export'); 1171 const archiveBtn = document.getElementById('doclib-bulk-archive'); 1172 const cloneBtn = document.getElementById('doclib-bulk-clone'); 1173 if (deleteBtn) deleteBtn.disabled = _librarySelectedIds.size === 0; 1174 if (exportBtn) exportBtn.disabled = _librarySelectedIds.size === 0; 1175 if (cloneBtn) cloneBtn.disabled = _librarySelectedIds.size === 0; 1176 if (archiveBtn) { 1177 archiveBtn.disabled = _librarySelectedIds.size === 0; 1178 archiveBtn.textContent = _libraryArchivedView ? 'Restore' : 'Archive'; 1179 } 1180 } 1181 1182 async function libraryDeleteSingle(docId, card) { 1183 if (uiModule && uiModule.styledConfirm) { 1184 const ok = await uiModule.styledConfirm('Delete this document?', { confirmText: 'Delete', danger: true }); 1185 if (!ok) return; 1186 } else if (!confirm('Delete this document?')) { 1187 return; 1188 } 1189 try { 1190 const res = await fetch(`${API_BASE}/api/document/${docId}`, { method: 'DELETE', credentials: 'same-origin' }); 1191 if (!res.ok) { 1192 let detail = `HTTP ${res.status}`; 1193 try { const j = await res.json(); if (j?.detail) detail = j.detail; } catch {} 1194 throw new Error(detail); 1195 } 1196 if (card) { 1197 card.classList.add('doclib-card-deleting'); 1198 card.addEventListener('transitionend', () => card.remove(), { once: true }); 1199 setTimeout(() => { if (card.parentElement) card.remove(); }, 400); 1200 } 1201 libraryRemoveDocumentFromState(docId); 1202 if (uiModule) uiModule.showToast('Document deleted'); 1203 } catch (e) { 1204 if (uiModule) uiModule.showError(`Failed to delete document: ${e.message || e}`); 1205 } 1206 } 1207 1208 async function libraryBulkDelete() { 1209 if (_librarySelectedIds.size === 0) return; 1210 const count = _librarySelectedIds.size;
1211 if (uiModule && uiModule.styledConfirm) { 1212 const ok = await uiModule.styledConfirm( 1213 `Delete ${count} document${count !== 1 ? 's' : ''}?`, 1214 { confirmText: 'Delete', danger: true } 1215 ); 1216 if (!ok) return; 1217 } else if (!confirm(`Delete ${count} document${count !== 1 ? 's' : ''}?`)) { 1218 return; 1219 } 1220 1221 let deleted = 0; 1222 let failed = 0; 1223 const deletedIds = []; 1224 for (const id of _librarySelectedIds) { 1225 try { 1226 const res = await fetch(`${API_BASE}/api/document/${id}`, { method: 'DELETE', credentials: 'same-origin' }); 1227 if (res.ok) { 1228 deleted++; 1229 deletedIds.push(id); 1230 } 1231 else { failed++; console.warn('Delete failed for', id, 'status', res.status); } 1232 } catch (e) { 1233 failed++; 1234 console.error('Failed to delete document:', id, e); 1235 } 1236 } 1237 1238 for (const id of deletedIds) { 1239 const card = document.querySelector(`.doclib-card[data-doc-id="${CSS.escape(String(id))}"]`); 1240 if (card) card.classList.add('doclib-card-deleting'); 1241 } 1242 if (deletedIds.length) await new Promise(r => setTimeout(r, 320)); 1243 libraryExitSelectMode(); 1244 await libraryFetch(false); 1245 if (uiModule) { 1246 const msg = failed > 0 1247 ? `Deleted ${deleted} · ${failed} failed` 1248 : `Deleted ${deleted} document${deleted !== 1 ? 's' : ''}`; 1249 (failed > 0 ? uiModule.showError : uiModule.showToast)(msg); 1250 } 1251 } 1252 1253 async function libraryBulkArchive() { 1254 if (_librarySelectedIds.size === 0) return; 1255 const toArchived = !_libraryArchivedView; 1256 const ids = [..._librarySelectedIds]; 1257 let done = 0, failed = 0; 1258 for (const id of ids) { 1259 try { 1260 const res = await fetch(`${API_BASE}/api/document/${id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' }); 1261 if (res.ok) done++; else failed++; 1262 } catch { failed++; } 1263 } 1264 libraryExitSelectMode(); 1265 await libraryFetch(false); 1266 if (uiModule) { 1267 const verb = toArchived ? 'Archived' : 'Restored'; 1268 const msg = failed > 0 ? `${verb} ${done} · ${failed} failed` : `${verb} ${done} document${done !== 1 ? 's' : ''}`; 1269 (failed > 0 ? uiModule.showError : uiModule.showToast)(msg); 1270 } 1271 } 1272 1273 // Bulk "Clone" â reuse libraryImportDocument for each selected doc. 1274 // It handles session resolution + a possible new-session creation once 1275 // (subsequent calls in the loop see the now-resolved session). 1276 async function libraryBulkClone() { 1277 if (_librarySelectedIds.size === 0) return; 1278 const ids = [..._librarySelectedIds]; 1279 let done = 0, failed = 0; 1280 for (const id of ids) { 1281 const doc = _libraryDocs.find(d => d.id === id); 1282 if (!doc) { failed++; continue; } 1283 try { 1284 const ok = await libraryImportDocument(doc); 1285 if (ok === false) failed++; else done++; 1286 } catch { failed++; } 1287 } 1288 libraryExitSelectMode(); 1289 if (uiModule) { 1290 const msg = failed > 0 1291 ? `Cloned ${done} · ${failed} failed` 1292 : `Cloned ${done} document${done !== 1 ? 's' : ''}`; 1293 (failed > 0 ? uiModule.showError : uiModule.showToast)(msg); 1294 } 1295 } 1296 1297 async function libraryBulkExport() { 1298 if (_librarySelectedIds.size === 0) return; 1299 // More than 5 â one server-built .zip (mirrors the gallery's bulk export; 1300 // browsers also block a flood of individual downloads). 1301 if (_librarySelectedIds.size > 5) { 1302 const ids = [..._librarySelectedIds]; 1303 try { 1304 if (uiModule) uiModule.showToast(`Zipping ${ids.length} documentsâ¦`); 1305 const res = await fetch(`${API_BASE}/api/documents/export-zip`, { 1306 method: 'POST', credentials: 'same-origin', 1307 headers: { 'Content-Type': 'application/json' }, 1308 body: JSON.stringify({ ids }), 1309 }); 1310 if (!res.ok) throw new Error('zip failed'); 1311 const blob = await res.blob(); 1312 const url = URL.createObjectURL(blob); 1313 const a = document.createElement('a'); 1314 a.href = url; 1315 a.download = 'documents.zip'; 1316 document.body.appendChild(a); 1317 a.click(); 1318 a.remove(); 1319 setTimeout(() => URL.revokeObjectURL(url), 2000); 1320 if (uiModule) uiModule.showToast(`Exported ${ids.length} documents (zip)`); 1321 } catch (e) { 1322 if (uiModule) uiModule.showError('Failed to create zip'); 1323 } 1324 return; 1325 } 1326 const extMap = { 1327 javascript: '.js', python: '.py', html: '.html', css: '.css', 1328 markdown: '.md', json: '.json', yaml: '.yml', bash: '.sh', 1329 sql: '.sql', rust: '.rs', go: '.go', java: '.java', c: '.c', cpp: '.cpp',
1330 typescript: '.ts', ruby: '.rb', php: '.php', text: '.txt', 1331 xml: '.xml', toml: '.toml', ini: '.ini', 1332 }; 1333 1334 const docs = await Promise.all([..._librarySelectedIds].map(async id => { 1335 try { 1336 const res = await fetch(`${API_BASE}/api/document/${id}`); 1337 if (!res.ok) return null; 1338 return await res.json(); 1339 } catch (e) { 1340 console.error('Failed to export document:', id, e); 1341 return null; 1342 } 1343 })); 1344 for (const doc of docs) { 1345 if (!doc) continue; 1346 const ext = extMap[doc.language] || '.txt'; 1347 const filename = (doc.title || 'document') + (doc.title && doc.title.includes('.') ? '' : ext); 1348 const blob = new Blob([doc.current_content || ''], { type: 'text/plain' }); 1349 const url = URL.createObjectURL(blob); 1350 const a = document.createElement('a'); 1351 a.href = url; 1352 a.download = filename; 1353 a.click(); 1354 URL.revokeObjectURL(url); 1355 } 1356 if (uiModule) uiModule.showToast(`Exported ${_librarySelectedIds.size} document${_librarySelectedIds.size !== 1 ? 's' : ''}`); 1357 } 1358 1359 /** Lazy-load SheetJS for spreadsheet parsing */ 1360 let _xlsxReady = null; 1361 function ensureXLSX() { 1362 if (_xlsxReady) return _xlsxReady; 1363 if (window.XLSX) return (_xlsxReady = Promise.resolve()); 1364 _xlsxReady = new Promise((resolve, reject) => { 1365 const s = document.createElement('script'); 1366 s.src = '/static/lib/xlsx.full.min.js'; 1367 s.onload = resolve; 1368 s.onerror = () => reject(new Error('Failed to load XLSX library')); 1369 document.head.appendChild(s); 1370 }); 1371 return _xlsxReady; 1372 } 1373 1374 let _mammothReady = null; 1375 function ensureMammoth() { 1376 if (_mammothReady) return _mammothReady; 1377 if (window.mammoth) return (_mammothReady = Promise.resolve()); 1378 _mammothReady = new Promise((resolve, reject) => { 1379 const s = document.createElement('script'); 1380 s.src = '/static/lib/mammoth.browser.min.js'; 1381 s.onload = resolve; 1382 s.onerror = () => reject(new Error('Failed to load DOCX library')); 1383 document.head.appendChild(s); 1384 }); 1385 return _mammothReady; 1386 } 1387 1388 /** Convert HTML from mammoth to clean markdown */ 1389 function htmlToMarkdown(html) { 1390 const doc = new DOMParser().parseFromString(html, 'text/html'); 1391 let md = ''; 1392 function walk(node) { 1393 if (node.nodeType === 3) { md += node.textContent; return; } 1394 if (node.nodeType !== 1) return; 1395 const tag = node.tagName.toLowerCase(); 1396 if (tag === 'h1') { md += '\n# '; walkChildren(node); md += '\n'; } 1397 else if (tag === 'h2') { md += '\n## '; walkChildren(node); md += '\n'; } 1398 else if (tag === 'h3') { md += '\n### '; walkChildren(node); md += '\n'; } 1399 else if (tag === 'h4') { md += '\n#### '; walkChildren(node); md += '\n'; } 1400 else if (tag === 'strong' || tag === 'b') { md += '**'; walkChildren(node); md += '**'; } 1401 else if (tag === 'em' || tag === 'i') { md += '*'; walkChildren(node); md += '*'; } 1402 else if (tag === 'a') { md += '['; walkChildren(node); md += `](${node.href || ''})`; } 1403 else if (tag === 'br') { md += '\n'; } 1404 else if (tag === 'p') { md += '\n'; walkChildren(node); md += '\n'; } 1405 else if (tag === 'ul' || tag === 'ol') { md += '\n'; walkChildren(node); } 1406 else if (tag === 'li') { 1407 const parent = node.parentElement?.tagName?.toLowerCase(); 1408 if (parent === 'ol') { 1409 const idx = Array.from(node.parentElement.children).indexOf(node) + 1; 1410 md += `${idx}. `; 1411 } else { md += '- '; } 1412 walkChildren(node); 1413 md += '\n'; 1414 } 1415 else if (tag === 'table') { md += '\n'; convertTable(node); md += '\n'; } 1416 else if (tag === 'img') { 1417 // Skip embedded base64 images â they produce huge unreadable blobs 1418 const src = node.src || ''; 1419 if (!src.startsWith('data:')) { 1420 md += ``; 1421 } else if (node.alt) { 1422 md += `*[image: ${node.alt}]*`; 1423 } 1424 } 1425 else { walkChildren(node); } 1426 } 1427 function walkChildren(node) { for (const child of node.childNodes) walk(child); } 1428 function convertTable(table) { 1429 const rows = table.querySelectorAll('tr'); 1430 rows.forEach((tr, i) => { 1431 const cells = tr.querySelectorAll('th, td'); 1432 md += '| ' + Array.from(cells).map(c => c.textContent.trim()).join(' | ') + ' |\n'; 1433 if (i === 0) md += '| ' + Array.from(cells).map(() => '---').join(' | ') + ' |\n'; 1434 }); 1435 } 1436 walkChildren(doc.body); 1437 return md.replace(/\n{3,}/g, '\n\n').trim(); 1438 } 1439 1440 /** Read file contents â handles text, spreadsheet, and DOCX formats */ 1441 async function readFileContent(file) { 1442 const name = file.name.toLowerCase(); 1443 const isSpreadsheet = name.endsWith('.xlsx') || name.endsWith('.xls') || name.endsWith('.ods'); 1444 const isDocx = name.endsWith('.docx'); 1445 1446 if (isSpreadsheet) { 1447 await ensureXLSX(); 1448 const buf = await file.arrayBuffer(); 1449 const wb = window.XLSX.read(buf, { type: 'array' }); 1450 // Convert each sheet to CSV, join with a header per sheet 1451 const parts = []; 1452 for (const sheetName of wb.SheetNames) { 1453 if (wb.SheetNames.length > 1) parts.push(`# Sheet: ${sheetName}`); 1454 parts.push(window.XLSX.utils.sheet_to_csv(wb.Sheets[sheetName])); 1455 } 1456 return parts.join('\n\n'); 1457 } 1458 1459 if (isDocx) { 1460 await ensureMammoth(); 1461 const buf = await file.arrayBuffer(); 1462 const result = await window.mammoth.convertToHtml({ arrayBuffer: buf }); 1463 return htmlToMarkdown(result.value); 1464 } 1465 1466 // Plain text 1467 return new Promise((resolve, reject) => { 1468 const reader = new FileReader(); 1469 reader.onload = () => resolve(reader.result); 1470 reader.onerror = () => reject(reader.error); 1471 reader.readAsText(file); 1472 }); 1473 } 1474 1475 /** Import files from disk into the document library */ 1476 async function libraryImportFiles(fileList) { 1477 const EXT_TO_LANG = { 1478 '.py': 'python', '.js': 'javascript', '.ts': 'typescript', 1479 '.html': 'html', '.htm': 'html', '.css': 'css', '.md': 'markdown', 1480 '.json': 'json', '.yml': 'yaml', '.yaml': 'yaml', '.sh': 'bash', 1481 '.bash': 'bash', '.sql': 'sql', '.rs': 'rust', '.go': 'go', 1482 '.java': 'java', '.c': 'c', '.cpp': 'cpp', '.h': 'c', '.hpp': 'cpp', 1483 '.rb': 'ruby', '.php': 'php', '.xml': 'xml', 1484 '.toml': 'toml', '.ini': 'ini', '.txt': '', '.log': '', 1485 '.cfg': 'ini', '.conf': 'ini', '.env': '', '.jsx': 'javascript', 1486 '.tsx': 'typescript', '.vue': 'html', '.svelte': 'html', 1487 '.scss': 'css', '.sass': 'css', '.less': 'css', 1488 '.csv': 'csv', '.tsv': 'csv',
1489 '.xlsx': 'csv', '.xls': 'csv', '.ods': 'csv', 1490 '.docx': 'markdown', '.doc': 'markdown', 1491 }; 1492 1493 let imported = 0; 1494 let failed = 0; 1495 let _firstErr = ''; 1496 1497 // Library imports aren't tied to a chat â the backend now accepts a 1498 // session-less "library" document, so no session_id is sent. 1499 for (const file of fileList) { 1500 try { 1501 const name = file.name; 1502 const dotIdx = name.lastIndexOf('.'); 1503 const ext = dotIdx >= 0 ? name.slice(dotIdx).toLowerCase() : ''; 1504 const baseTitle = dotIdx > 0 ? name.slice(0, dotIdx) : name; 1505 const language = EXT_TO_LANG[ext] !== undefined ? EXT_TO_LANG[ext] : null; 1506 1507 const isSpreadsheet = ['.xlsx', '.xls', '.ods'].includes(ext); 1508 const isPdf = ext === '.pdf'; 1509 1510 if (isPdf) { 1511 // Backend handles save + AcroForm detection in one shot â picks the 1512 // right doc kind so fillable forms get clickable inputs in the PDF 1513 // view, and plain PDFs get the static page-image viewer. 1514 const fd = new FormData(); 1515 fd.append('file', file); 1516 const res = await fetch(`${API_BASE}/api/documents/import-pdf`, { 1517 method: 'POST', 1518 body: fd, 1519 }); 1520 if (!res.ok) { 1521 let _e = `HTTP ${res.status}`; 1522 try { const _j = await res.json(); _e = _j.detail || _j.error || _e; } catch {} 1523 throw new Error('PDF import failed: ' + _e); 1524 } 1525 imported++; 1526 continue; 1527 } 1528 1529 if (isSpreadsheet) { 1530 // Multi-sheet: create one document per sheet 1531 await ensureXLSX(); 1532 const buf = await file.arrayBuffer(); 1533 const wb = window.XLSX.read(buf, { type: 'array' }); 1534 for (const sheetName of wb.SheetNames) { 1535 const csv = window.XLSX.utils.sheet_to_csv(wb.Sheets[sheetName]); 1536 if (!csv.trim()) continue; 1537 const sheetTitle = wb.SheetNames.length > 1 1538 ? `${baseTitle} - ${sheetName}` : baseTitle; 1539 const res = await fetch(`${API_BASE}/api/document`, { 1540 method: 'POST', 1541 headers: { 'Content-Type': 'application/json' }, 1542 body: JSON.stringify({ title: sheetTitle, language: 'csv', content: csv }), 1543 }); 1544 if (!res.ok) throw new Error('Server error'); 1545 } 1546 imported++; 1547 } else { 1548 const content = await readFileContent(file); 1549 const res = await fetch(`${API_BASE}/api/document`, { 1550 method: 'POST', 1551 headers: { 'Content-Type': 'application/json' }, 1552 body: JSON.stringify({ title: baseTitle, language, content }), 1553 }); 1554 if (!res.ok) throw new Error('Server error'); 1555 imported++; 1556 } 1557 } catch (e) { 1558 console.error('Failed to import file:', file.name, e); 1559 if (!_firstErr) _firstErr = (e && e.message) || String(e); 1560 failed++; 1561 } 1562 } 1563 1564 const msg = `Imported ${imported} file${imported !== 1 ? 's' : ''}` + 1565 (failed ? `, ${failed} failed${_firstErr ? ' â ' + _firstErr : ''}` : ''); 1566 if (failed && uiModule) uiModule.showError(msg); 1567 else if (uiModule) uiModule.showToast(msg); 1568 await libraryFetch(false); 1569 } 1570 1571 export function openLibrary(opts) { 1572 if (_libraryOpen) { 1573 // Recover from stuck state: the swipe-to-dismiss in ui.js adds .hidden 1574 // to the modal without calling closeLibrary, so _libraryOpen can stay 1575 // true even though the modal is gone or invisible. Detect and reset. 1576 const existing = document.getElementById('doclib-modal'); 1577 if (!existing || existing.classList.contains('hidden')) { 1578 if (existing) existing.remove(); 1579 _libraryOpen = false; 1580 } else { 1581 return; 1582 } 1583 } 1584 _libraryOpen = true; 1585 _libraryImportMode = !!(opts && opts.import); 1586 _librarySelectMode = false; 1587 _librarySelectedIds.clear(); 1588 _librarySearch = ''; 1589 _libraryActiveLanguage = null; 1590 _librarySort = 'recent'; 1591 _libraryOffset = 0; 1592 _libraryDocs = []; 1593 1594 // Create modal 1595 const modal = document.createElement('div'); 1596 modal.className = 'modal'; 1597 modal.id = 'doclib-modal'; 1598 modal.innerHTML = ` 1599 <div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);max-height:85vh;background:var(--bg);"> 1600 <div class="modal-header"> 1601 <!-- Header title + icon mirror the currently-active sub-tab (Chats / 1602 Documents / Research / Archive) so the user sees ONE icon at 1603 the top representing the section they're in, with the tab
1604 strip below as sub-navigation. _switchLibTab() updates this. --> 1605 <h4 id="doclib-header-title"><span id="doclib-header-icon" style="vertical-align:-2px;margin-right:4px;display:inline-flex;"></span><span id="doclib-header-text">Library</span></h4> 1606 <button class="close-btn" id="doclib-close">\u2716</button> 1607 </div> 1608 <div class="lib-tabs" id="doclib-lib-tabs" style="padding:0 10px;"> 1609 <button class="lib-tab" data-doclib-tab="chats"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Chats</button> 1610 <button class="lib-tab active" data-doclib-tab="documents"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>Documents</button> 1611 <button class="lib-tab" data-doclib-tab="research"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px;margin-right:3px;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>Research</button> 1612 <button class="lib-tab" data-doclib-tab="archive"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>Archive</button> 1613 </div> 1614 <div class="modal-body" style="display:flex;flex-direction:column;gap:10px;overflow:hidden;"> 1615 <div id="doclib-panel-chats" data-doclib-panel="chats" class="admin-card" style="display:none;flex:1;flex-direction:column;overflow:hidden;"> 1616 <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;"> 1617 <h2 style="margin:0;padding:0;line-height:1;">Chats <span id="doclib-chats-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2> 1618 </div> 1619 <p class="memory-desc doclib-desc">All active chat sessions. Click to open.</p> 1620 <div class="memory-toolbar"> 1621 <div class="memory-category-filters"> 1622 <select class="memory-sort-select" id="doclib-chats-sort"> 1623 <option value="recent">Recent</option> 1624 <option value="oldest">Oldest</option> 1625 <option value="most-messages">Most messages</option> 1626 <option value="alpha">A\u2013Z</option> 1627 </select> 1628 <button class="memory-toolbar-btn" id="doclib-chats-select-btn">Select</button> 1629 <button class="memory-toolbar-btn" id="doclib-chats-tidy-btn" title="AI tidy: delete junk sessions and organize into folders">
1629<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy</button> 1630 </div> 1631 <input type="text" id="doclib-chats-search" placeholder="Search chats\u2026" class="memory-search-input" /> 1632 <div id="doclib-chats-chips" class="doclib-lang-chips"></div> 1633 </div> 1634 <div id="doclib-chats-bulk" class="memory-bulk-bar hidden" style="margin-bottom:5px;"> 1635 <label class="memory-bulk-check-all" style="position:relative;top:0px;left:-1px;"><input type="checkbox" id="doclib-chats-select-all" style="position:relative;top:0px;"> All</label> 1636 <span id="doclib-chats-selected-count">0 Selected</span> 1637 <button class="memory-toolbar-btn" id="doclib-chats-bulk-archive" style="position:relative;top:-3px;left:2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>Archive</button> 1638 <button class="memory-toolbar-btn danger" id="doclib-chats-bulk-delete" style="position:relative;left:2px;">
1638<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button> 1639 <button class="memory-toolbar-btn" id="doclib-chats-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;left:2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button> 1640 </div> 1641 <div id="doclib-chats-grid" class="doclib-grid"></div> 1642 </div> 1643 <div id="doclib-panel-archive" data-doclib-panel="archive" class="admin-card" style="display:none;flex:1;flex-direction:column;overflow:hidden;"> 1644 <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;"> 1645 <h2 style="margin:0;padding:0;line-height:1;position:relative;top:2px;">Archive <span id="doclib-arc-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2> 1646 </div> 1647 <p class="memory-desc doclib-desc" style="position:relative;top:0.5px;">Archived sessions. Restore to make active again.</p> 1648 <div class="memory-toolbar"> 1649 <div class="memory-category-filters"> 1650 <select class="memory-sort-select" id="doclib-arc-sort"> 1651 <option value="recent">Recent</option> 1652 <option value="oldest">Oldest</option> 1653 <option value="most-messages">Most messages</option> 1654 <option value="alpha">A\u2013Z</option> 1655 </select> 1656 <button class="memory-toolbar-btn" id="doclib-arc-select-btn">Select</button> 1657 </div> 1658 <input type="text" id="doclib-arc-search" placeholder="Search archive\u2026" class="memory-search-input" /> 1659 <div id="doclib-arc-chips" class="doclib-lang-chips"></div> 1660 </div> 1661 <div id="doclib-arc-bulk" class="memory-bulk-bar hidden" style="margin-bottom:5px;"> 1662 <label class="memory-bulk-check-all" style="position:relative;top:0px;left:1px;"><input type="checkbox" id="doclib-arc-select-all"> All</label> 1663 <span id="doclib-arc-selected-count">0 Selected</span> 1664 <button class="memory-toolbar-btn" id="doclib-arc-bulk-restore" style="position:relative;top:-3px;">
1664<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>Restore</button> 1665 <button class="memory-toolbar-btn danger" id="doclib-arc-bulk-delete"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button> 1666 <button class="memory-toolbar-btn" id="doclib-arc-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button> 1667 </div> 1668 <div id="doclib-arc-grid" class="doclib-grid"></div> 1669 </div> 1670 <div id="doclib-panel-research" data-doclib-panel="research" class="admin-card" style="display:none;flex:1;flex-direction:column;overflow:hidden;"> 1671 <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:10px;"> 1672 <h2 style="margin:0;padding:0;line-height:1;">Research <span id="doclib-research-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2> 1673 </div> 1674 <p class="memory-desc doclib-desc" style="position:relative;top:-1px;">Completed deep research reports. Click to view.</p> 1675 <div class="memory-toolbar"> 1676 <div class="memory-category-filters"> 1677 <select class="memory-sort-select" id="doclib-research-sort"> 1678 <option value="recent">Recent</option> 1679 <option value="oldest">Oldest</option> 1680 <option value="most-sources">Most sources</option> 1681 <option value="alpha">A\u2013Z</option> 1682 </select> 1683 <button class="memory-toolbar-btn" id="doclib-research-select-btn">Select</button> 1684 <button class="memory-toolbar-btn" id="doclib-research-tidy-btn" title="Tidy: delete research with no sources or empty reports">Tidy</button> 1685 </div> 1686 <input type="text" id="doclib-research-search" placeholder="Search research\u2026" class="memory-search-input" /> 1687 </div> 1688 <div id="doclib-research-bulk" class="memory-bulk-bar hidden" style="margin-bottom:5px;"> 1689 <label class="memory-bulk-check-all" style="position:relative;top:0px;left:1px;"><input type="checkbox" id="doclib-research-select-all"> All</label> 1690 <span id="doclib-research-selected-count">0 Selected</span> 1691 <button class="memory-toolbar-btn" id="doclib-research-bulk-archive" style="position:relative;top:-2px;">
1691<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>Archive</button> 1692 <button class="memory-toolbar-btn danger" id="doclib-research-bulk-delete" style="position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button> 1693 <button class="memory-toolbar-btn" id="doclib-research-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button> 1694 </div> 1695 <div id="doclib-research-grid" class="doclib-grid"></div> 1696 </div> 1697 <div data-doclib-panel="documents" class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;"> 1698 <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;"> 1699 <h2 style="margin:0;padding:0;line-height:1;">Documents <span id="doclib-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2> 1700 <button class="memory-toolbar-btn" id="doclib-import-file-btn" title="Import files from disk" style="margin-left:auto;">
1700<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:2px;"><polyline points="7 10 12 5 17 10"/><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="21" x2="19" y2="21"/></svg> Import</button> 1701 <button class="memory-toolbar-btn" id="doclib-create-btn" title="Create new blank document"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg> Create</button> 1702 </div> 1703 <p class="memory-desc doclib-desc">Open documents in a session, clone to a new or import new files.</p> 1704 <div class="memory-toolbar"> 1705 <div class="memory-category-filters"> 1706 <select class="memory-sort-select" id="doclib-sort"> 1707 <option value="recent">Recent</option> 1708 <option value="oldest">Oldest</option> 1709 <option value="edits">Most edits</option> 1710 <option value="alpha">A\u2013Z</option> 1711 </select> 1712 <button class="memory-toolbar-btn" id="doclib-select-btn" title="Select documents">Select</button> 1713 <button class="memory-toolbar-btn" id="doclib-tidy-btn" title="Tidy: remove empty / junk / duplicate documents">Tidy</button> 1714 </div> 1715 <input type="text" id="doclib-search" placeholder="Search titles & content\u2026" class="memory-search-input" /> 1716 <div id="doclib-chips" class="doclib-lang-chips"></div> 1717 </div> 1718 <input type="file" id="doclib-file-input" multiple style="display:none" /> 1719 <div id="doclib-bulk-bar" class="memory-bulk-bar hidden" style="margin-bottom:5px;"> 1720 <label class="memory-bulk-check-all" style="position:relative;top:0px;left:1px;"><input type="checkbox" id="doclib-select-all" /> All</label> 1721 <span id="doclib-selected-count">0 Selected</span> 1722 <button id="doclib-bulk-actions" class="memory-toolbar-btn" style="position:relative;top:-2px;margin-left:auto;">
1722<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>Actions <span style="opacity:0.55;font-size:9px;">▼</span></button> 1723 <button id="doclib-bulk-cancel" class="memory-toolbar-btn" title="Cancel (Esc)" style="margin-left:4px;margin-right:4px;padding:3px 6px;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button> 1724 </div> 1725 <div class="doclib-grid" id="doclib-grid"></div> 1726 <button class="doclib-load-more" id="doclib-load-more" style="display:none">Load more</button> 1727 </div> 1728 </div> 1729 </div> 1730 `; 1731 document.body.appendChild(modal); 1732 1733 // Make modal draggable (same logic as other modals) 1734 { 1735 const content = modal.querySelector('.modal-content'); 1736 const header = modal.querySelector('.modal-header'); 1737 if (content && header) { 1738 // Restore saved position / fullscreen state 1739 try { 1740 const saved = JSON.parse(localStorage.getItem('doclib-pos')); 1741 if (saved && saved.fullscreen) { 1742 localStorage.removeItem('doclib-pos'); 1743 } else if (saved && saved.left && saved.top) { 1744 content.style.position = 'fixed'; 1745 content.style.left = saved.left; 1746 content.style.top = saved.top; 1747 content.style.margin = '0'; 1748 // Clamp to viewport in case window was resized 1749 requestAnimationFrame(() => { 1750 const r = content.getBoundingClientRect(); 1751 if (r.right > window.innerWidth) content.style.left = Math.max(0, window.innerWidth - r.width - 8) + 'px'; 1752 if (r.bottom > window.innerHeight) content.style.top = Math.max(0, window.innerHeight - r.height - 8) + 'px'; 1753 if (r.left < 0) content.style.left = '8px'; 1754 if (r.top < 0) content.style.top = '8px'; 1755 }); 1756 } 1757 } catch {} 1758 // Replaced ~150 lines of inline drag/snap/dock with one helper call. 1759 // Library intentionally disables top-edge fullscreen snap: that layout 1760 // breaks dense icon/tool rows. Side docking still works. 1761 const FS_CLASS = 'doclib-fullscreen'; 1762 const enterFullscreen = () => { 1763 if (modal.classList.contains(FS_CLASS)) return; 1764 modal.classList.add(FS_CLASS); 1765 content.style.position = 'fixed'; 1766 content.style.left = '0'; 1767 content.style.top = '0'; 1768 content.style.right = '0'; 1769 content.style.bottom = '0'; 1770 content.style.width = '100vw'; 1771 content.style.maxWidth = '100vw'; 1772 content.style.height = '100vh'; 1773 content.style.maxHeight = '100vh'; 1774 content.style.borderRadius = '0'; 1775 content.style.margin = '0'; 1776 content.style.transform = 'none'; 1777 try { localStorage.setItem('doclib-pos', JSON.stringify({ fullscreen: true })); } catch {} 1778 }; 1779 const exitFullscreen = (cx, cy) => { 1780 if (!modal.classList.contains(FS_CLASS)) return; 1781 modal.classList.remove(FS_CLASS); 1782 content.style.width = ''; 1783 content.style.maxWidth = ''; 1784 content.style.height = ''; 1785 content.style.maxHeight = ''; 1786 content.style.borderRadius = ''; 1787 content.style.right = ''; 1788 content.style.bottom = ''; 1789 const r0 = content.getBoundingClientRect(); 1790 const w = r0.width || Math.min(900, window.innerWidth * 0.92); 1791 content.style.left = Math.max(8, cx - w / 2) + 'px'; 1792 content.style.top = Math.max(8, cy - 20) + 'px'; 1793 }; 1794 makeWindowDraggable(modal, { 1795 content, 1796 header, 1797 fsClass: FS_CLASS,
1798 skipSelector: '.modal-close', 1799 onEnterFullscreen: enterFullscreen, 1800 onExitFullscreen: exitFullscreen, 1801 enableFullscreen: false, 1802 onDragEnd: () => { 1803 try { localStorage.setItem('doclib-pos', JSON.stringify({ left: content.style.left, top: content.style.top })); } catch {} 1804 }, 1805 }); 1806 } 1807 } 1808 1809 // Wire events 1810 document.getElementById('doclib-close').addEventListener('click', closeLibrary); 1811 1812 // Tab switching â Chats / Documents / Archive / Research 1813 let _activeLibTab = (opts && opts.tab) || 'documents'; 1814 const _tabBtns = modal.querySelectorAll('[data-doclib-tab]'); 1815 const _tabPanels = modal.querySelectorAll('[data-doclib-panel]'); 1816 1817 // Client-side pagination for tabs whose API returns everything at once 1818 // (chats/archive/research). Render only this many initially; the 1819 // load-more button reveals more in chunks. 1820 const _LIB_PAGE_SIZE = 20; 1821 let _chatsVisibleLimit = _LIB_PAGE_SIZE; 1822 let _arcVisibleLimit = _LIB_PAGE_SIZE; 1823 let _researchVisibleLimit = _LIB_PAGE_SIZE; 1824 1825 function _appendInlineLoadMore(grid, totalCount, currentLimit, onClick) { 1826 if (!grid || !grid.parentElement) return; 1827 // Drop the previous instance (if any) â we re-render the list from 1828 // scratch each pass, so the button is regenerated alongside it. 1829 grid.parentElement.querySelectorAll(':scope > .doclib-inline-load-more').forEach(b => b.remove()); 1830 if (totalCount <= currentLimit) return; 1831 const btn = document.createElement('button'); 1832 btn.className = 'doclib-load-more doclib-inline-load-more'; 1833 btn.textContent = `Load more (${currentLimit} of ${totalCount})`; 1834 btn.addEventListener('click', onClick); 1835 grid.parentElement.appendChild(btn); 1836 } 1837 1838 // SVG markup + label for each tab â used to keep the modal header 1839 // in sync with whichever sub-tab the user is on. 1840 const _TAB_HEADERS = { 1841 chats: { 1842 label: 'Chats', 1843 svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>', 1844 }, 1845 documents: { 1846 label: 'Documents', 1847 svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>', 1848 }, 1849 research: { 1850 label: 'Research', 1851 svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>', 1852 }, 1853 archive: { 1854 label: 'Archive', 1855 svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>', 1856 }, 1857 }; 1858 1859 function _switchLibTab(tab) { 1860 _activeLibTab = tab; 1861 _tabBtns.forEach(b => b.classList.toggle('active', b.dataset.doclibTab === tab)); 1862 _tabPanels.forEach(p => { 1863 if (p.dataset.doclibPanel === tab) { 1864 p.style.display = 'flex'; 1865 } else { 1866 p.style.display = 'none'; 1867 } 1868 }); 1869 // Sync the modal header icon + label to match the active sub-tab. 1870 const hdr = _TAB_HEADERS[tab]; 1871 if (hdr) { 1872 const ico = document.getElementById('doclib-header-icon'); 1873 const txt = document.getElementById('doclib-header-text'); 1874 if (ico) ico.innerHTML = hdr.svg; 1875 if (txt) txt.textContent = hdr.label; 1876 } 1877 if (tab === 'chats') _renderLibChats(); 1878 else if (tab === 'archive') _renderLibArchive(); 1879 else if (tab === 'research') _renderLibResearch(); 1880 } 1881 1882 _tabBtns.forEach(btn => { 1883 btn.addEventListener('click', () => _switchLibTab(btn.dataset.doclibTab)); 1884 }); 1885 1886 // ââ Chats tab state ââ 1887 let _chatsSessions = []; 1888 let _chatsSearch = ''; 1889 let _chatsSort = 'recent'; 1890 let _chatsSelectMode = false;
1891 const _chatsSelected = new Set(); 1892 let _chatsModelFilter = ''; 1893 1894 function _renderLibChats() { 1895 const grid = document.getElementById('doclib-chats-grid'); 1896 if (!grid) return; 1897 grid.innerHTML = ''; 1898 grid.appendChild(spinnerModule.createLoadingRow('Loadingâ¦')); 1899 fetch(API_BASE + '/api/sessions', { credentials: 'same-origin' }).then(r => r.json()).then(data => { 1900 const raw = Array.isArray(data) ? data : (data.sessions || []); 1901 _chatsSessions = raw.filter(s => !s.archived); 1902 _renderChatsGrid(); 1903 _renderChatsChips(); 1904 }).catch(() => { grid.innerHTML = '<div class="doclib-empty">Failed to load</div>'; }); 1905 } 1906 1907 // Tap a chat row to expand inline: fetches the recent messages and 1908 // renders them as a preview with an "Open chat" button. Tap again to 1909 // collapse. Mirrors the documents-tab expand pattern. 1910 async function _toggleChatPreview(card, session) { 1911 const preview = card.querySelector('.doclib-chat-preview'); 1912 if (!preview) return; 1913 const isOpen = card.classList.contains('doclib-card-expanded'); 1914 // Collapse any other open preview in this grid first 1915 const grid = card.closest('.doclib-grid'); 1916 if (grid) { 1917 grid.querySelectorAll('.doclib-card-expanded').forEach(c => { 1918 if (c !== card) { 1919 c.classList.remove('doclib-card-expanded'); 1920 const p = c.querySelector('.doclib-chat-preview'); 1921 if (p) { p.style.display = 'none'; p.innerHTML = ''; } 1922 } 1923 }); 1924 } 1925 if (isOpen) { 1926 card.classList.remove('doclib-card-expanded'); 1927 preview.style.display = 'none'; 1928 preview.innerHTML = ''; 1929 return; 1930 } 1931 card.classList.add('doclib-card-expanded'); 1932 preview.style.display = 'block'; 1933 preview.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Loadingâ¦</div>'; 1934 try { 1935 const res = await fetch(`${API_BASE}/api/history/${session.id}`, { credentials: 'same-origin' }); 1936 if (!res.ok) throw new Error('Failed'); 1937 const data = await res.json(); 1938 const history = Array.isArray(data) ? data : (data.history || []); 1939 const recent = history.filter(m => m.role === 'user' || m.role === 'assistant').slice(-5); 1940 const sessionModel = (session.model || '').split('/').pop(); 1941 const msgsHtml = recent.length 1942 ? recent.map(m => { 1943 const isUser = m.role === 'user'; 1944 const raw = m.content || ''; 1945 const truncated = raw.length > 600 ? raw.slice(0, 600) + 'â¦' : raw; 1946 // Strip thinking blocks (internal model state) and render with 1947 // the same markdown pipeline the chat uses. 1948 const cleaned = truncated 1949 .replace(/<think>[\s\S]*?<\/think>/g, '') 1950 .replace(/<think>[\s\S]*$/, '') 1951 .trim(); 1952 let body; 1953 try { 1954 body = markdownModule.mdToHtml(cleaned); 1955 } catch { body = _esc(cleaned); } 1956 // Per-message model can override the session default (e.g. 1957 // when comparing models in the same chat). 1958 const msgModel = (m.metadata && (m.metadata.model || m.metadata.model_name)) || ''; 1959 const modelTag = !isUser && (msgModel || sessionModel) 1960 ? `<span class="doclib-chat-msg-model">${_esc(msgModel || sessionModel)}</span>` 1961 : ''; 1962 return `<div class="doclib-chat-bubble-row ${isUser ? 'user' : 'assistant'}"> 1963 <div class="doclib-chat-bubble"> 1964 ${modelTag} 1965 <div class="doclib-chat-bubble-body">${body}</div> 1966 </div> 1967 </div>`; 1968 }).join('') 1969 : '<div style="opacity:0.4;font-size:11px;padding:6px 4px;">No messages yet</div>'; 1970 const isArchive = !!session.archived; 1971 // Archived chats get a Restore button (unarchive); active chats get the 1972 // Archive button. Matches the research + document archive previews. 1973 const archiveHtml = isArchive 1974 ? '<button class="doclib-chat-restore-btn">' +
1975 '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>' + 1976 'Restore' + 1977 '</button>' 1978 : '<button class="doclib-chat-archive-btn">' + 1979 '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>' + 1980 'Archive' + 1981 '</button>'; 1982 // Copy sits next to Archive on the left side of the action row. 1983 // Uses the same border-only secondary-action style â distinct from 1984 // the danger Delete (red) and the primary Open (right-aligned). 1985 // Copy is hidden in the Archive (keep the footer to Delete + Restore + 1986 // Open there). It still shows for active chats. 1987 const copyHtml = isArchive ? '' : '<button class="doclib-chat-copy-btn">' +
1988 '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>' + 1989 'Copy' + 1990 '</button>'; 1991 const deleteHtml = '<button class="doclib-chat-delete-btn">' + 1992 '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>' + 1993 'Delete' + 1994 '</button>'; 1995 preview.innerHTML = 1996 '<div class="doclib-chat-preview-messages">' + msgsHtml + '</div>' + 1997 '<div class="doclib-chat-preview-actions">' + 1998 deleteHtml + 1999 archiveHtml + 2000 copyHtml + 2001 '<button class="doclib-chat-open-btn">' +
2002 '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>' + 2003 'Open' + 2004 '</button>' + 2005 '</div>'; 2006 const openBtn = preview.querySelector('.doclib-chat-open-btn'); 2007 if (openBtn) openBtn.addEventListener('click', (e) => { 2008 e.stopPropagation(); 2009 if (window.sessionModule) window.sessionModule.selectSession(session.id); 2010 closeLibrary(); 2011 // Also collapse the wide sidebar so the picked chat sits 2012 // fullscreen â same gesture as picking a session in the 2013 // sidebar itself on mobile. Skip on desktop where the user 2014 // expects the sidebar to stay where they left it. 2015 if (window.innerWidth <= 768) { 2016 const sb = document.getElementById('sidebar'); 2017 if (sb) { 2018 sb.classList.add('hidden'); 2019 try { window.syncRailSide && window.syncRailSide(); } catch (_) {} 2020 } 2021 } 2022 }); 2023 const archiveBtn = preview.querySelector('.doclib-chat-archive-btn'); 2024 if (archiveBtn) archiveBtn.addEventListener('click', async (e) => { 2025 e.stopPropagation(); 2026 await fetch(API_BASE + '/api/session/' + session.id + '/archive', { 2027 method: 'POST', 2028 headers: { 'Content-Type': 'application/json' }, 2029 }); 2030 _renderLibChats(); 2031 }); 2032 const restoreBtn = preview.querySelector('.doclib-chat-restore-btn'); 2033 if (restoreBtn) restoreBtn.addEventListener('click', async (e) => { 2034 e.stopPropagation(); 2035 await fetch(API_BASE + '/api/session/' + session.id + '/unarchive', { method: 'POST' }); 2036 _renderLibArchive(); 2037 }); 2038 const copyBtn = preview.querySelector('.doclib-chat-copy-btn'); 2039 if (copyBtn) copyBtn.addEventListener('click', (e) => { 2040 e.stopPropagation(); 2041 _copyChatById(session.id); 2042 }); 2043 const deleteBtn = preview.querySelector('.doclib-chat-delete-btn'); 2044 if (deleteBtn) deleteBtn.addEventListener('click', async (e) => { 2045 e.stopPropagation(); 2046 if (!await window.styledConfirm('Delete this chat?', { confirmText: 'Delete', danger: true })) return; 2047 await fetch(API_BASE + '/api/session/' + session.id, { method: 'DELETE' }); 2048 card.style.maxHeight = `${Math.max(card.getBoundingClientRect().height, card.scrollHeight)}px`; 2049 card.classList.add('memory-tidy-removing'); 2050 await new Promise(r => setTimeout(r, 520)); 2051 if (isArchive) _renderLibArchive(); else _renderLibChats(); 2052 }); 2053 } catch (e) { 2054 preview.innerHTML = '<div style="opacity:0.5;font-size:11px;padding:6px 4px;color:var(--color-error);">Failed to load preview</div>'; 2055 } 2056 } 2057 2058 function _renderChatsGrid() { 2059 const grid = document.getElementById('doclib-chats-grid'); 2060 if (!grid) return; 2061 const _csb = document.getElementById('doclib-chats-select-btn'); 2062 if (_csb) { _csb.classList.toggle('active', _chatsSelectMode); _csb.textContent = _chatsSelectMode ? 'Cancel' : 'Select'; } 2063 let filtered = _chatsSessions.slice(); 2064 if (_chatsSearch) { 2065 const q = _chatsSearch.toLowerCase(); 2066 filtered = filtered.filter(s => (s.name || '').toLowerCase().includes(q) || (s.model || '').toLowerCase().includes(q)); 2067 } 2068 if (_chatsModelFilter) filtered = filtered.filter(s => s.folder === _chatsModelFilter); 2069 if (_chatsSort === 'oldest') filtered.sort((a, b) => (a.updated_at || '') > (b.updated_at || '') ? 1 : -1); 2070 else if (_chatsSort === 'most-messages') filtered.sort((a, b) => (b.message_count || 0) - (a.message_count || 0)); 2071 else if (_chatsSort === 'alpha') filtered.sort((a, b) => (a.name || '').localeCompare(b.name || '')); 2072 else filtered.sort((a, b) => (b.updated_at || '') > (a.updated_at || '') ? 1 : -1); 2073 2074 const stats = document.getElementById('doclib-chats-stats'); 2075 if (stats) stats.textContent = filtered.length + ' chat' + (filtered.length !== 1 ? 's' : ''); 2076 2077 if (!filtered.length) { 2078 // Sad-mouth smiley (downturn curve) for "nothing here yet". 2079 const _sadIco = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('sad') + '</span>'; 2080 grid.innerHTML = '<div class="doclib-empty">No chats' + _sadIco + '</div>'; 2081 _appendInlineLoadMore(grid, 0, _chatsVisibleLimit, () => {}); 2082 return; 2083 }
2084 const total = filtered.length; 2085 const visible = filtered.slice(0, _chatsVisibleLimit); 2086 grid.innerHTML = ''; 2087 _maybeCascadeGrid(grid, 'chats'); 2088 for (const s of visible) { 2089 const card = document.createElement('div'); 2090 card.className = 'memory-item doclib-chat-row'; 2091 card.style.cursor = 'pointer'; 2092 card.dataset.sid = s.id; 2093 const model = (s.model || '').split('/').pop(); 2094 const cbHtml = _chatsSelectMode ? '<input type="checkbox" class="memory-select-cb"' + (_chatsSelected.has(s.id) ? ' checked' : '') + '>' : ''; 2095 const chatIconSvg = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.4;flex-shrink:0;"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'; 2096 const chevronSvg = '<span class="doclib-card-chevron"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>'; 2097 // Msg count badge inside the title, dimmer than the name so it 2098 // reads as metadata at a glance. Hidden when count is 0 so 2099 // brand-new "New Chat" rows don't show "\u00b7 0 msgs". 2100 const _chatMsgs = s.message_count || 0; 2101 const msgCountHtml = _chatMsgs > 0 2102 ? '<span style="opacity:0.45;font-weight:normal;font-size:0.9em;margin-left:6px;">\u00b7 ' + _chatMsgs + ' msg' + (_chatMsgs === 1 ? '' : 's') + '</span>' 2103 : ''; 2104 card.innerHTML = 2105 '<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' + 2106 cbHtml + 2107 '<div style="flex:1;min-width:0;">
2107' + 2108 '<div class="memory-item-title">' + chatIconSvg + _esc(s.name || 'Untitled') + msgCountHtml + '</div>' + 2109 '<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">' + [model, _relTime(s.updated_at)].filter(Boolean).join(' \u00b7 ') + '</div>' + 2110 '</div>' + 2111 chevronSvg + 2112 '<div class="memory-item-actions"><button class="memory-item-btn _chat-menu" title="Actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>' + 2113 '</div>' + 2114 '<div class="doclib-chat-preview" style="display:none;"></div>'; 2115 const cb = card.querySelector('.memory-select-cb'); 2116 if (cb) { cb.addEventListener('click', e => e.stopPropagation()); cb.addEventListener('change', () => { if (cb.checked) _chatsSelected.add(s.id); else _chatsSelected.delete(s.id); _updateChatsCount(); }); } 2117 card.querySelector('._chat-menu').addEventListener('click', (e) => { e.stopPropagation(); _showLibDropdown(e.currentTarget, [ 2118 { label: 'Open', action: () => { if (window.sessionModule) window.sessionModule.selectSession(s.id); } }, 2119 { label: 'Copy', action: () => _copyChatById(s.id) }, 2120 { label: 'Archive', action: async () => { await fetch(API_BASE + '/api/session/' + s.id + '/archive', { method: 'POST', headers: {'Content-Type':'application/json'} }); _renderLibChats(); } }, 2121 { label: 'Delete', action: async () => { 2122 if (!await window.styledConfirm('Delete this chat?', { confirmText: 'Delete', danger: true })) return; 2123 await fetch(API_BASE + '/api/session/' + s.id, { method: 'DELETE' }); 2124 card.style.maxHeight = `${Math.max(card.getBoundingClientRect().height, card.scrollHeight)}px`; 2125 card.classList.add('memory-tidy-removing'); 2126 await new Promise(r => setTimeout(r, 520)); 2127 _renderLibChats(); 2128 }, danger: true }, 2129 ], { onSelect: () => { 2130 _chatsSelectMode = true; 2131 _chatsSelected.add(s.id); 2132 document.getElementById('doclib-chats-bulk')?.classList.remove('hidden'); 2133 _renderChatsGrid(); 2134 } }); }); 2135 card.addEventListener('click', (e) => { 2136 if (card._suppressNextClick) { card._suppressNextClick = false; return; } 2137 if (_chatsSelectMode) { const c = card.querySelector('.memory-select-cb'); if (c) { c.checked = !c.checked; if (c.checked) _chatsSelected.add(s.id); else _chatsSelected.delete(s.id); _updateChatsCount(); } return; } 2138 if (e.target.closest('._chat-menu') || e.target.closest('.memory-select-cb') || e.target.closest('.doclib-chat-open-btn')) return; 2139 _toggleChatPreview(card, s); 2140 }); 2141 _attachLongPressMenu(card, '._chat-menu'); 2142 grid.appendChild(card); 2143 } 2144 _appendInlineLoadMore(grid, total, _chatsVisibleLimit, () => { 2145 _chatsVisibleLimit += _LIB_PAGE_SIZE; 2146 _renderChatsGrid(); 2147 }); 2148 } 2149 2150 function _renderChatsChips() { 2151 const el = document.getElementById('doclib-chats-chips'); 2152 if (!el) return; 2153 const counts = {}; 2154 _chatsSessions.forEach(s => { const f = s.folder; if (f) counts[f] = (counts[f] || 0) + 1; }); 2155 const folders = Object.keys(counts).sort(); 2156 if (folders.length < 1) { el.innerHTML = ''; return; } 2157 el.innerHTML = ''; 2158 const mk = (label, val, count) => { const c = document.createElement('button'); c.className = 'memory-cat-chip' + (_chatsModelFilter === val ? ' active' : ''); c.textContent = label + ' (' + count + ')'; c.addEventListener('click', () => { _chatsModelFilter = _chatsModelFilter === val ? '' : val; _renderChatsGrid(); _renderChatsChips(); }); el.appendChild(c); }; 2159 mk('all', '', _chatsSessions.length); 2160 folders.forEach(f => mk(f, f, counts[f])); 2161 } 2162 2163 function _updateChatsCount() { const el = document.getElementById('doclib-chats-selected-count'); if (el) el.textContent = _chatsSelected.size + ' Selected'; } 2164 2165 // Chats event listeners 2166 document.getElementById('doclib-chats-sort').addEventListener('change', (e) => { _chatsSort = e.target.value; _renderChatsGrid(); }); 2167 document.getElementById('doclib-chats-search').addEventListener('input', (e) => { _chatsSearch = e.target.value.trim(); _renderChatsGrid(); }); 2168 document.getElementById('doclib-chats-select-btn').addEventListener('click', () => { _chatsSelectMode = !_chatsSelectMode; _chatsSelected.clear(); document.getElementById('doclib-chats-bulk').classList.toggle('hidden', !_chatsSelectMode); _renderChatsGrid(); }); 2169 document.getElementById('doclib-chats-bulk-cancel')?.addEventListener('click', () => { 2170 _chatsSelectMode = false; _chatsSelected.clear(); 2171 document.getElementById('doclib-chats-bulk').classList.add('hidden'); 2172 _renderChatsGrid(); 2173 }); 2174 function _chatsToggleAll() { 2175 const allCb = document.getElementById('doclib-chats-select-all');
2176 const newState = _chatsSelected.size < _chatsSessions.length; 2177 if (allCb) allCb.checked = newState; 2178 document.querySelectorAll('#doclib-chats-grid .memory-select-cb').forEach(cb => { cb.checked = newState; }); 2179 _chatsSessions.forEach(s => { if (newState) _chatsSelected.add(s.id); else _chatsSelected.delete(s.id); }); 2180 _updateChatsCount(); 2181 } 2182 document.getElementById('doclib-chats-select-all').addEventListener('change', _chatsToggleAll); 2183 document.getElementById('doclib-chats-bulk').addEventListener('click', (e) => { 2184 if (e.target.closest('button') || e.target.closest('input')) return; 2185 _chatsToggleAll(); 2186 }); 2187 document.getElementById('doclib-chats-bulk-archive').addEventListener('click', async () => { 2188 const count = _chatsSelected.size; 2189 if (!count) return; 2190 const grid = document.getElementById('doclib-chats-grid'); 2191 if (grid) { 2192 grid.querySelectorAll('.doclib-card').forEach(card => { 2193 const sid = card.dataset.sid || card.dataset.sessionId; 2194 if (sid && _chatsSelected.has(sid)) { 2195 card.style.transition = 'opacity 0.25s, transform 0.25s'; 2196 card.style.opacity = '0'; 2197 card.style.transform = 'scale(0.95)'; 2198 } 2199 }); 2200 } 2201 await new Promise(r => setTimeout(r, 250)); 2202 const ids = [..._chatsSelected]; 2203 const results = await Promise.all( 2204 ids.map(sid => fetch(API_BASE + '/api/session/' + sid + '/archive', { method: 'POST', headers: {'Content-Type':'application/json'} }) 2205 .then(r => ({ sid, ok: r.ok })) 2206 .catch(() => ({ sid, ok: false })) 2207 ) 2208 ); 2209 const failed = results.filter(r => !r.ok).map(r => r.sid); 2210 if (failed.length && grid) { 2211 grid.querySelectorAll('.doclib-card').forEach(card => { 2212 const sid = card.dataset.sid || card.dataset.sessionId; 2213 if (sid && failed.includes(sid)) { 2214 card.style.opacity = ''; 2215 card.style.transform = ''; 2216 } 2217 }); 2218 if (window.uiModule) window.uiModule.showError(`Failed to archive ${failed.length} of ${ids.length} chat${ids.length > 1 ? 's' : ''}`); 2219 } 2220 _chatsSelected.clear(); 2221 _chatsSelectMode = false; 2222 document.getElementById('doclib-chats-bulk').classList.add('hidden'); 2223 _renderLibChats(); 2224 }); 2225 document.getElementById('doclib-chats-bulk-delete').addEventListener('click', async () => { 2226 const count = _chatsSelected.size; 2227 if (!count) return; 2228 if (!await window.styledConfirm(`Delete ${count} chat${count > 1 ? 's' : ''}? This cannot be undone.`, { confirmText: 'Delete', danger: true })) return; 2229 // Fade out selected cards 2230 const grid = document.getElementById('doclib-chats-grid'); 2231 if (grid) { 2232 grid.querySelectorAll('.doclib-card').forEach(card => { 2233 const sid = card.dataset.sid || card.dataset.sessionId; 2234 if (sid && _chatsSelected.has(sid)) { 2235 card.style.transition = 'opacity 0.25s, transform 0.25s'; 2236 card.style.opacity = '0'; 2237 card.style.transform = 'scale(0.95)'; 2238 } 2239 }); 2240 } 2241 // Delete after animation. v2 review HIGH-8: inspect each response 2242 // so cards that the server rejected get restored (instead of 2243 // staying faded out forever) and the user sees an aggregate 2244 // error toast. 2245 await new Promise(r => setTimeout(r, 250)); 2246 const ids = [..._chatsSelected]; 2247 const results = await Promise.all( 2248 ids.map(sid => fetch(API_BASE + '/api/session/' + sid, { method: 'DELETE' }) 2249 .then(r => ({ sid, ok: r.ok })) 2250 .catch(() => ({ sid, ok: false })) 2251 ) 2252 ); 2253 const failed = results.filter(r => !r.ok).map(r => r.sid); 2254 if (failed.length && grid) { 2255 // Restore faded cards for the rows the server refused. 2256 grid.querySelectorAll('.doclib-card').forEach(card => { 2257 const sid = card.dataset.sid || card.dataset.sessionId; 2258 if (sid && failed.includes(sid)) { 2259 card.style.opacity = ''; 2260 card.style.transform = ''; 2261 } 2262 }); 2263 if (window.uiModule) window.uiModule.showError(`Failed to delete ${failed.length} of ${ids.length} chat${ids.length > 1 ? 's' : ''}`); 2264 } 2265 _chatsSelected.clear(); 2266 _chatsSelectMode = false;
2267 document.getElementById('doclib-chats-bulk').classList.add('hidden'); 2268 _renderLibChats(); 2269 }); 2270 2271 // Tidy button â AI cleanup + organize into folders 2272 document.getElementById('doclib-chats-tidy-btn').addEventListener('click', async () => { 2273 const tidyBtn = document.getElementById('doclib-chats-tidy-btn'); 2274 const origHTML = tidyBtn.innerHTML; 2275 tidyBtn.disabled = true; 2276 tidyBtn.classList.add('spinning'); 2277 tidyBtn.textContent = ''; 2278 // Silent whirlpool, nudged up to line up with the surrounding button 2279 // text in the Chats header. The previous version checked 2280 // `window.spinnerModule` (never bound) and always fell through to a 2281 // plain "Tidying..." label. 2282 const sp = spinnerModule.create('', 'clean', 'whirlpool'); 2283 const el = sp.createElement(); 2284 el.style.position = 'relative'; 2285 el.style.top = '1px'; 2286 tidyBtn.appendChild(el); 2287 sp.start(); 2288 try { 2289 const res = await fetch(API_BASE + '/api/sessions/auto-sort', { method: 'POST', credentials: 'same-origin' }); 2290 const data = await res.json(); 2291 if (!res.ok) throw new Error(data.detail || 'Tidy failed'); 2292 if (data.status === 'ok') { 2293 if (window.uiModule) window.uiModule.showToast('Sorted ' + data.updated + ' sessions into ' + data.folders.length + ' folders'); 2294 if (window.sessionModule) await window.sessionModule.loadSessions(); 2295 _renderLibChats(); 2296 } else { 2297 if (window.uiModule) window.uiModule.showToast(data.reason || 'Nothing to tidy'); 2298 } 2299 } catch (e) { 2300 if (window.uiModule) window.uiModule.showError('Tidy: ' + e.message); 2301 } finally { 2302 tidyBtn.disabled = false; 2303 tidyBtn.classList.remove('spinning'); 2304 tidyBtn.innerHTML = origHTML; 2305 } 2306 }); 2307 2308 // ââ Archive tab state ââ 2309 let _arcSessions = []; 2310 let _arcDocs = []; // archived documents 2311 let _arcResearch = []; // archived research reports 2312 let _arcSearch = ''; 2313 let _arcSort = 'recent'; 2314 let _arcSelectMode = false; 2315 const _arcSelected = new Set(); 2316 let _arcModelFilter = ''; 2317 let _arcTypeFilter = ''; // '', 'chats', 'documents', 'research' 2318 2319 function _renderLibArchive() { 2320 const grid = document.getElementById('doclib-arc-grid'); 2321 if (!grid) return; 2322 grid.innerHTML = ''; 2323 grid.appendChild(spinnerModule.createLoadingRow('Loadingâ¦')); 2324 // Archive tab is the home for ALL archived items â chats, documents, and 2325 // research â each rendered with its own icon. Load the three in parallel. 2326 Promise.all([ 2327 fetch(API_BASE + '/api/sessions/archived?limit=100&sort=recent', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})), 2328 fetch(API_BASE + '/api/documents/library?archived=true&limit=50', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})), 2329 fetch('/api/research/library?archived=true', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})), 2330 ]).then(([s, d, r]) => { 2331 // These are all archived by definition â flag them so the expanded 2332 // chat preview hides its (redundant) "Archive" button. 2333 _arcSessions = (s.sessions || []).map(x => ({ ...x, archived: true })); 2334 _arcDocs = d.documents || []; 2335 _arcResearch = (r.research || []).map(x => ({ ...x, archived: true })); 2336 _renderArcGrid(); 2337 _renderArcChips(); 2338 }).catch(() => { grid.innerHTML = '<div class="doclib-empty">Failed to load</div>'; }); 2339 } 2340 2341 // Inline expand/collapse for an archived DOCUMENT card (chat-style). Loads 2342 // the doc content into the card's .doclib-chat-preview. Lag-safe: caps the 2343 // shown text and skips highlighting (archived previews are read-only peeks). 2344 async function _toggleArcDocPreview(card, d) { 2345 const preview = card.querySelector('.doclib-chat-preview'); 2346 if (!preview) return; 2347 const grid = card.closest('.doclib-grid'); 2348 if (grid) { 2349 grid.querySelectorAll('.doclib-card-expanded').forEach(c => { 2350 if (c !== card) { 2351 c.classList.remove('doclib-card-expanded'); 2352 const p = c.querySelector('.doclib-chat-preview'); 2353 if (p) { p.style.display = 'none'; p.innerHTML = ''; } 2354 } 2355 }); 2356 } 2357 if (card.classList.contains('doclib-card-expanded')) { 2358 card.classList.remove('doclib-card-expanded'); 2359 preview.style.display = 'none'; preview.innerHTML = ''; 2360 return; 2361 } 2362 card.classList.add('doclib-card-expanded'); 2363 preview.style.display = 'block'; 2364 preview.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Loadingâ¦</div>';
2365 try { 2366 const res = await fetch(`${API_BASE}/api/document/${d.id}`, { credentials: 'same-origin' }); 2367 if (!res.ok) throw new Error('failed'); 2368 const full = await res.json(); 2369 const content = (full.current_content || '').slice(0, 20000); 2370 const pre = document.createElement('pre'); 2371 pre.style.cssText = 'white-space:pre-wrap;word-break:break-word;font-size:11px;margin:6px 4px;max-height:50vh;overflow:auto;'; 2372 pre.textContent = content || '(empty document)'; 2373 preview.innerHTML = ''; 2374 preview.appendChild(pre); 2375 2376 // Footer â uses the same visible .doclib-chat-preview-actions style as 2377 // the chat/research previews (the .doclib-card-expanded-actions class is 2378 // display:none unless inside a .doclib-card, which these archive rows 2379 // are not). Delete + Restore, matching the others. 2380 const actions = document.createElement('div'); 2381 actions.className = 'doclib-chat-preview-actions'; 2382 actions.innerHTML = 2383 '<button class="doclib-chat-delete-btn">
2383<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>' + 2384 '<button class="doclib-chat-restore-btn"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>Restore</button>' + 2385 '<button class="doclib-chat-open-btn"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>Open</button>'; 2386 actions.querySelector('.doclib-chat-delete-btn').addEventListener('click', async (ev) => { 2387 ev.stopPropagation(); 2388 if (!await window.styledConfirm('Delete this document?', { confirmText: 'Delete', danger: true })) return; 2389 await fetch(`${API_BASE}/api/document/${d.id}`, { method: 'DELETE', credentials: 'same-origin' }); 2390 _renderLibArchive(); 2391 }); 2392 actions.querySelector('.doclib-chat-restore-btn').addEventListener('click', async (ev) => { 2393 ev.stopPropagation(); 2394 await fetch(`${API_BASE}/api/document/${d.id}/archive?archived=false`, { method: 'POST', credentials: 'same-origin' }); 2395 _renderLibArchive(); 2396 }); 2397 // Open = clone the doc into the active session and surface it in the editor. 2398 actions.querySelector('.doclib-chat-open-btn').addEventListener('click', (ev) => { 2399 ev.stopPropagation(); 2400 libraryImportDocument(d); 2401 }); 2402 preview.appendChild(actions); 2403 } catch { 2404 preview.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Failed to load preview</div>'; 2405 } 2406 } 2407 2408 function _renderArcGrid() { 2409 const grid = document.getElementById('doclib-arc-grid'); 2410 if (!grid) return; 2411 const _asb = document.getElementById('doclib-arc-select-btn'); 2412 if (_asb) { _asb.classList.toggle('active', _arcSelectMode); _asb.textContent = _arcSelectMode ? 'Cancel' : 'Select'; } 2413 let filtered = _arcSessions.slice(); 2414 if (_arcSearch) { 2415 const q = _arcSearch.toLowerCase(); 2416 filtered = filtered.filter(s => (s.name || '').toLowerCase().includes(q) || (s.model || '').toLowerCase().includes(q)); 2417 } 2418 if (_arcModelFilter) filtered = filtered.filter(s => (s.model || '').split('/').pop() === _arcModelFilter); 2419 if (_arcSort === 'oldest') filtered.sort((a, b) => (a.updated_at || '') > (b.updated_at || '') ? 1 : -1); 2420 else if (_arcSort === 'most-messages') filtered.sort((a, b) => (b.message_count || 0) - (a.message_count || 0)); 2421 else if (_arcSort === 'alpha') filtered.sort((a, b) => (a.name || '').localeCompare(b.name || '')); 2422 else filtered.sort((a, b) => (b.updated_at || '') > (a.updated_at || '') ? 1 : -1); 2423 2424 // Archived documents + research also live here â filter them by the same search. 2425 const _aq = (_arcSearch || '').toLowerCase(); 2426 let filtDocs = _aq ? _arcDocs.filter(d => (d.title || '').toLowerCase().includes(_aq)) : _arcDocs; 2427 let filtResearch = _aq ? _arcResearch.filter(r => (r.query || '').toLowerCase().includes(_aq)) : _arcResearch; 2428 2429 // Type filter chips (Chats / Documents / Research) zero out the others. 2430 const _showChats = !_arcTypeFilter || _arcTypeFilter === 'chats'; 2431 const _showDocs = !_arcTypeFilter || _arcTypeFilter === 'documents'; 2432 const _showResearch = !_arcTypeFilter || _arcTypeFilter === 'research'; 2433 if (!_showChats) filtered = []; 2434 if (!_showDocs) filtDocs = []; 2435 if (!_showResearch) filtResearch = []; 2436 2437 const stats = document.getElementById('doclib-arc-stats'); 2438 if (stats) stats.textContent = (filtered.length + filtDocs.length + filtResearch.length) + ' archived'; 2439 2440 if (!filtered.length && !filtDocs.length && !filtResearch.length) { 2441 // Neutral / no-smile face for "nothing archived here". 2442 const _neutralIco = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('neutral') + '</span>'; 2443 grid.innerHTML = '<div class="doclib-empty">No archived items' + _neutralIco + '</div>'; 2444 _appendInlineLoadMore(grid, 0, _arcVisibleLimit, () => {}); 2445 return; 2446 }
2447 const total = filtered.length; 2448 const visible = filtered.slice(0, _arcVisibleLimit); 2449 grid.innerHTML = ''; 2450 _maybeCascadeGrid(grid, 'archive'); 2451 for (const s of visible) { 2452 const card = document.createElement('div'); 2453 card.className = 'memory-item doclib-chat-row'; 2454 card.style.cursor = 'pointer'; 2455 card.dataset.sid = s.id; 2456 card.dataset.arckey = 'chats:' + s.id; 2457 const model = (s.model || '').split('/').pop(); 2458 const cbHtml = _arcSelectMode ? '<input type="checkbox" class="memory-select-cb" data-arckey="chats:' + s.id + '"' + (_arcSelected.has('chats:' + s.id) ? ' checked' : '') + '>' : ''; 2459 const arcIconSvg = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.5;flex-shrink:0;"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'; 2460 card.innerHTML = 2461 '<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' + 2462 cbHtml + 2463 '<div style="flex:1;min-width:0;">
2463' + 2464 '<div class="memory-item-title">' + arcIconSvg + _esc(s.name || 'Untitled') + '</div>' + 2465 '<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">' + [model, _relTime(s.updated_at)].filter(Boolean).join(' \u00b7 ') + '</div>' + 2466 '</div>' + 2467 '<div class="memory-item-actions"><button class="memory-item-btn _arc-menu" title="Actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>' + 2468 '</div>' + 2469 '<div class="doclib-chat-preview" style="display:none;"></div>'; 2470 const cb = card.querySelector('.memory-select-cb'); 2471 if (cb) { cb.addEventListener('click', e => e.stopPropagation()); cb.addEventListener('change', () => { if (cb.checked) _arcSelected.add('chats:' + s.id); else _arcSelected.delete('chats:' + s.id); _updateArcCount(); }); } 2472 card.querySelector('._arc-menu').addEventListener('click', (e) => { e.stopPropagation(); _showLibDropdown(e.currentTarget, [ 2473 { label: 'Open', action: () => { if (window.sessionModule) window.sessionModule.selectSession(s.id); } }, 2474 { label: 'Copy', action: () => _copyChatById(s.id) }, 2475 { label: 'Restore', action: async () => { await fetch(API_BASE + '/api/session/' + s.id + '/unarchive', { method: 'POST' }); _renderLibArchive(); } }, 2476 { label: 'Delete', action: async () => { 2477 if (!await window.styledConfirm('Delete this chat permanently?', { confirmText: 'Delete', danger: true })) return; 2478 await fetch(API_BASE + '/api/session/' + s.id, { method: 'DELETE' }); 2479 _renderLibArchive(); 2480 }, danger: true }, 2481 ], { onSelect: () => { 2482 _arcSelectMode = true; 2483 _arcSelected.add('chats:' + s.id); 2484 document.getElementById('doclib-arc-bulk')?.classList.remove('hidden'); 2485 _renderArcGrid(); 2486 } }); }); 2487 card.addEventListener('click', (e) => { 2488 if (card._suppressNextClick) { card._suppressNextClick = false; return; } 2489 if (_arcSelectMode) { const c = card.querySelector('.memory-select-cb'); if (c) { c.checked = !c.checked; if (c.checked) _arcSelected.add('chats:' + s.id); else _arcSelected.delete('chats:' + s.id); _updateArcCount(); } return; } 2490 if (e.target.closest('._arc-menu') || e.target.closest('.memory-select-cb') || e.target.closest('.doclib-chat-open-btn')) return; 2491 _toggleChatPreview(card, s); 2492 }); 2493 _attachLongPressMenu(card, '._arc-menu'); 2494 grid.appendChild(card); 2495 } 2496 // Archived DOCUMENTS â document icon, Restore / Delete. 2497 const _arcDocIco = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.5;flex-shrink:0;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>'; 2498 for (const d of filtDocs) { 2499 const card = document.createElement('div'); 2500 card.className = 'memory-item doclib-chat-row'; 2501 card.style.cursor = 'pointer'; 2502 card.dataset.arckey = 'documents:' + d.id; 2503 const _dcb = _arcSelectMode ? '<input type="checkbox" class="memory-select-cb" data-arckey="documents:' + d.id + '"' + (_arcSelected.has('documents:' + d.id) ? ' checked' : '') + '>' : ''; 2504 card.innerHTML = 2505 '<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' + 2506 _dcb + 2507 '<div style="flex:1;min-width:0;">
2507' + 2508 '<div class="memory-item-title">' + _arcDocIco + _esc(d.title || 'Untitled') + '</div>' + 2509 '<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">' + ['Document', (d.language || 'text'), _relTime(d.updated_at)].filter(Boolean).join(' · ') + '</div>' + 2510 '</div>' + 2511 '<div class="memory-item-actions"><button class="memory-item-btn _arc-doc-menu" title="Actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>' + 2512 '</div>' + 2513 '<div class="doclib-chat-preview" style="display:none;"></div>'; 2514 const _dcbEl = card.querySelector('.memory-select-cb'); 2515 if (_dcbEl) { _dcbEl.addEventListener('click', e => e.stopPropagation()); _dcbEl.addEventListener('change', () => { if (_dcbEl.checked) _arcSelected.add('documents:' + d.id); else _arcSelected.delete('documents:' + d.id); _updateArcCount(); }); } 2516 card.addEventListener('click', (e) => { 2517 if (e.target.closest('._arc-doc-menu') || e.target.closest('.memory-select-cb')) return; 2518 if (_arcSelectMode) { const c = card.querySelector('.memory-select-cb'); if (c) { c.checked = !c.checked; if (c.checked) _arcSelected.add('documents:' + d.id); else _arcSelected.delete('documents:' + d.id); _updateArcCount(); } return; } 2519 _toggleArcDocPreview(card, d); 2520 }); 2521 card.querySelector('._arc-doc-menu').addEventListener('click', (e) => { e.stopPropagation(); _showLibDropdown(e.currentTarget, [ 2522 { label: 'Restore', action: async () => { await fetch(API_BASE + '/api/document/' + d.id + '/archive?archived=false', { method: 'POST', credentials: 'same-origin' }); _renderLibArchive(); } }, 2523 { label: 'Delete', danger: true, action: async () => { if (!await window.styledConfirm('Delete this document?', { confirmText: 'Delete', danger: true })) return; await fetch(API_BASE + '/api/document/' + d.id, { method: 'DELETE', credentials: 'same-origin' }); _renderLibArchive(); } }, 2524 ], { onSelect: () => { 2525 _arcSelectMode = true; 2526 _arcSelected.add('documents:' + d.id); 2527 document.getElementById('doclib-arc-bulk')?.classList.remove('hidden'); 2528 _renderArcGrid(); 2529 } }); }); 2530 _attachLongPressMenu(card, '._arc-doc-menu'); 2531 grid.appendChild(card); 2532 } 2533 // Archived RESEARCH â magnifier icon, Open / Restore / Delete. 2534 const _arcResIco = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.5;flex-shrink:0;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>'; 2535 for (const r of filtResearch) { 2536 const card = document.createElement('div'); 2537 card.className = 'memory-item doclib-chat-row'; 2538 card.style.cursor = 'pointer'; 2539 card.dataset.arckey = 'research:' + r.id; 2540 const _rcb = _arcSelectMode ? '<input type="checkbox" class="memory-select-cb" data-arckey="research:' + r.id + '"' + (_arcSelected.has('research:' + r.id) ? ' checked' : '') + '>' : ''; 2541 card.innerHTML = 2542 '<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' + 2543 _rcb + 2544 '<div style="flex:1;min-width:0;">
2544' + 2545 '<div class="memory-item-title">' + _arcResIco + _esc(r.query || 'Research') + '</div>' + 2546 '<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">' + ['Research', (r.source_count ? r.source_count + ' sources' : ''), _relTime(r.completed_at ? new Date(r.completed_at * 1000).toISOString() : '')].filter(Boolean).join(' · ') + '</div>' + 2547 '</div>' + 2548 '<div class="memory-item-actions"><button class="memory-item-btn _arc-res-menu" title="Actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>' + 2549 '</div>' + 2550 '<div class="doclib-chat-preview" style="display:none;"></div>'; 2551 const _rcbEl = card.querySelector('.memory-select-cb'); 2552 if (_rcbEl) { _rcbEl.addEventListener('click', e => e.stopPropagation()); _rcbEl.addEventListener('change', () => { if (_rcbEl.checked) _arcSelected.add('research:' + r.id); else _arcSelected.delete('research:' + r.id); _updateArcCount(); }); } 2553 card.addEventListener('click', (e) => { 2554 if (e.target.closest('._arc-res-menu') || e.target.closest('.memory-select-cb')) return; 2555 if (_arcSelectMode) { const c = card.querySelector('.memory-select-cb'); if (c) { c.checked = !c.checked; if (c.checked) _arcSelected.add('research:' + r.id); else _arcSelected.delete('research:' + r.id); _updateArcCount(); } return; } 2556 _toggleResearchPreview(card, r); 2557 }); 2558 card.querySelector('._arc-res-menu').addEventListener('click', (e) => { e.stopPropagation(); _showLibDropdown(e.currentTarget, [ 2559 { label: 'Open', action: () => { const a = document.createElement('a'); a.href = '/api/research/report/' + r.id; a.target = '_blank'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); } }, 2560 { label: 'Restore', action: async () => { await fetch('/api/research/' + r.id + '/archive?archived=false', { method: 'POST', credentials: 'same-origin' }); _renderLibArchive(); } }, 2561 { label: 'Delete', danger: true, action: async () => { if (!await window.styledConfirm('Delete this research?', { confirmText: 'Delete', danger: true })) return; await fetch('/api/research/' + r.id, { method: 'DELETE', credentials: 'same-origin' }); _renderLibArchive(); } }, 2562 ], { onSelect: () => { 2563 _arcSelectMode = true; 2564 _arcSelected.add('research:' + r.id); 2565 document.getElementById('doclib-arc-bulk')?.classList.remove('hidden'); 2566 _renderArcGrid(); 2567 } }); }); 2568 _attachLongPressMenu(card, '._arc-res-menu'); 2569 grid.appendChild(card); 2570 } 2571 _appendInlineLoadMore(grid, total, _arcVisibleLimit, () => { 2572 _arcVisibleLimit += _LIB_PAGE_SIZE; 2573 _renderArcGrid(); 2574 }); 2575 } 2576 2577 function _renderArcChips() { 2578 const el = document.getElementById('doclib-arc-chips'); 2579 if (!el) return; 2580 // Type filters: All / Chats / Documents / Research (only the ones present). 2581 el.innerHTML = ''; 2582 const mk = (label, val, count) => { 2583 const c = document.createElement('button'); 2584 c.className = 'memory-cat-chip' + (_arcTypeFilter === val ? ' active' : ''); 2585 c.textContent = label + ' (' + count + ')'; 2586 c.addEventListener('click', () => { _arcTypeFilter = _arcTypeFilter === val ? '' : val; _renderArcGrid(); _renderArcChips(); }); 2587 el.appendChild(c); 2588 }; 2589 const total = _arcSessions.length + _arcDocs.length + _arcResearch.length; 2590 if (!total) return; 2591 mk('All', '', total); 2592 if (_arcSessions.length) mk('Chats', 'chats', _arcSessions.length); 2593 if (_arcDocs.length) mk('Documents', 'documents', _arcDocs.length); 2594 if (_arcResearch.length) mk('Research', 'research', _arcResearch.length); 2595 } 2596 2597 function _updateArcCount() { const el = document.getElementById('doclib-arc-selected-count'); if (el) el.textContent = _arcSelected.size + ' Selected'; } 2598 2599 // Archive event listeners 2600 document.getElementById('doclib-arc-sort').addEventListener('change', (e) => { _arcSort = e.target.value; _renderArcGrid(); }); 2601 document.getElementById('doclib-arc-search').addEventListener('input', (e) => { _arcSearch = e.target.value.trim(); _renderArcGrid(); }); 2602 document.getElementById('doclib-arc-select-btn').addEventListener('click', () => { _arcSelectMode = !_arcSelectMode; _arcSelected.clear(); document.getElementById('doclib-arc-bulk').classList.toggle('hidden', !_arcSelectMode); _renderArcGrid(); }); 2603 document.getElementById('doclib-arc-bulk-cancel')?.addEventListener('click', () => { 2604 _arcSelectMode = false; _arcSelected.clear(); 2605 document.getElementById('doclib-arc-bulk').classList.add('hidden'); 2606 _renderArcGrid(); 2607 }); 2608 // Select-all toggles EVERY visible archived card (chats + docs + research), 2609 // keyed by the card's composite "type:id" data-arckey. 2610 function _arcToggleAll() { 2611 const cbs = document.querySelectorAll('#doclib-arc-grid .memory-select-cb'); 2612 const newState = _arcSelected.size < cbs.length; 2613 const allCb = document.getElementById('doclib-arc-select-all'); 2614 if (allCb) allCb.checked = newState;
2615 cbs.forEach(cb => { 2616 cb.checked = newState; 2617 const k = cb.dataset.arckey; 2618 if (k) { if (newState) _arcSelected.add(k); else _arcSelected.delete(k); } 2619 }); 2620 _updateArcCount(); 2621 } 2622 document.getElementById('doclib-arc-select-all').addEventListener('change', _arcToggleAll); 2623 document.getElementById('doclib-arc-bulk').addEventListener('click', (e) => { 2624 if (e.target.closest('button') || e.target.closest('input')) return; 2625 _arcToggleAll(); 2626 }); 2627 // Route a composite "type:id" key to the right restore / delete endpoint. 2628 function _arcRestoreOne(key) { 2629 const i = key.indexOf(':'), type = key.slice(0, i), id = key.slice(i + 1); 2630 if (type === 'documents') return fetch(API_BASE + '/api/document/' + id + '/archive?archived=false', { method: 'POST', credentials: 'same-origin' }); 2631 if (type === 'research') return fetch('/api/research/' + id + '/archive?archived=false', { method: 'POST', credentials: 'same-origin' }); 2632 return fetch(API_BASE + '/api/session/' + id + '/unarchive', { method: 'POST', credentials: 'same-origin' }); 2633 } 2634 function _arcDeleteOne(key) { 2635 const i = key.indexOf(':'), type = key.slice(0, i), id = key.slice(i + 1); 2636 if (type === 'documents') return fetch(API_BASE + '/api/document/' + id, { method: 'DELETE', credentials: 'same-origin' }); 2637 if (type === 'research') return fetch('/api/research/' + id, { method: 'DELETE', credentials: 'same-origin' }); 2638 return fetch(API_BASE + '/api/session/' + id, { method: 'DELETE', credentials: 'same-origin' }); 2639 } 2640 document.getElementById('doclib-arc-bulk-restore').addEventListener('click', async () => { 2641 if (!_arcSelected.size) return; 2642 await Promise.all([..._arcSelected].map(_arcRestoreOne)); 2643 _arcSelected.clear(); _arcSelectMode = false; 2644 document.getElementById('doclib-arc-bulk').classList.add('hidden'); 2645 _renderLibArchive(); 2646 }); 2647 document.getElementById('doclib-arc-bulk-delete').addEventListener('click', async () => { 2648 const count = _arcSelected.size; 2649 if (!count) return; 2650 if (!await window.styledConfirm(`Delete ${count} archived item${count > 1 ? 's' : ''} permanently?`, { confirmText: 'Delete', danger: true })) return; 2651 const grid = document.getElementById('doclib-arc-grid'); 2652 if (grid) { 2653 grid.querySelectorAll('.memory-item[data-arckey]').forEach(card => { 2654 if (_arcSelected.has(card.dataset.arckey)) { 2655 card.style.transition = 'opacity 0.25s, transform 0.25s'; 2656 card.style.opacity = '0'; 2657 card.style.transform = 'scale(0.95)'; 2658 } 2659 }); 2660 } 2661 await new Promise(r => setTimeout(r, 250)); 2662 await Promise.all([..._arcSelected].map(_arcDeleteOne)); 2663 _arcSelected.clear(); 2664 _arcSelectMode = false; 2665 document.getElementById('doclib-arc-bulk').classList.add('hidden'); 2666 _renderLibArchive(); 2667 }); 2668 2669 // ââ Research tab ââ 2670 let _researchItems = []; 2671 let _researchSearch = ''; 2672 let _researchSelectMode = false; 2673 let _researchArchivedView = false; 2674 const _researchSelected = new Set(); 2675 2676 async function _renderLibResearch() { 2677 const grid = document.getElementById('doclib-research-grid'); 2678 const stats = document.getElementById('doclib-research-stats'); 2679 if (!grid) return; 2680 // Show our whirlpool spinner instead of the plain "Loading..." text. 2681 grid.innerHTML = ''; 2682 try { 2683 const _spm = (await import('./spinner.js')).default; 2684 const _sp = _spm.createWhirlpool(22); 2685 _sp.element.style.cssText = 'margin:18px auto;display:block;'; 2686 grid.appendChild(_sp.element); 2687 } catch { grid.innerHTML = '<div class="hwfit-loading">Loadingâ¦</div>'; } 2688 try { 2689 const res = await fetch('/api/research/library' + (_researchArchivedView ? '?archived=true' : ''), { credentials: 'same-origin' }); 2690 if (!res.ok) throw new Error(res.statusText); 2691 const data = await res.json(); 2692 _researchItems = data.research || data || []; 2693 } catch (e) { 2694 grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${_esc(e.message)}</div>`; 2695 return; 2696 } 2697 _renderResearchGrid(); 2698 } 2699 2700 // Toggle inline preview for a research row. Mirrors _toggleChatPreview 2701 // but pulls research-specific metadata: query, sources list (truncated), 2702 // followed by an "Open" action that loads the full report. 2703 async function _toggleResearchPreview(card, item) { 2704 const preview = card.querySelector('.doclib-chat-preview'); 2705 if (!preview) return; 2706 const isOpen = card.classList.contains('doclib-card-expanded'); 2707 const grid = card.closest('.doclib-grid'); 2708 if (grid) { 2709 grid.querySelectorAll('.doclib-card-expanded').forEach(c => { 2710 if (c !== card) { 2711 c.classList.remove('doclib-card-expanded'); 2712 const p = c.querySelector('.doclib-chat-preview'); 2713 if (p) { p.style.display = 'none'; p.innerHTML = ''; } 2714 } 2715 }); 2716 } 2717 if (isOpen) { 2718 card.classList.remove('doclib-card-expanded'); 2719 preview.style.display = 'none'; 2720 preview.innerHTML = ''; 2721 return; 2722 } 2723 card.classList.add('doclib-card-expanded'); 2724 preview.style.display = 'block'; 2725 preview.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Loadingâ¦</div>'; 2726 let detail = item;
2727 try { 2728 // Hit the per-research detail endpoint to pull sources + summary. 2729 // The library list endpoint only returns lightweight metadata. 2730 const res = await fetch(`${API_BASE}/api/research/detail/${item.id}`, { credentials: 'same-origin' }); 2731 if (res.ok) detail = await res.json(); 2732 } catch {} 2733 const sources = Array.isArray(detail.sources) ? detail.sources : []; 2734 const sourcesList = sources.slice(0, 12).map((src, i) => { 2735 const title = _esc(src.title || src.url || `Source ${i + 1}`); 2736 const url = _safeResearchHref(src.url); 2737 return url 2738 ? `<li><a href="${url}" target="_blank" rel="noopener">${title}</a></li>` 2739 : `<li>${title}</li>`; 2740 }).join(''); 2741 const sourcesHtml = sources.length 2742 ? `<div class="doclib-research-sources"><div class="doclib-research-section-label">Sources (${sources.length})</div><ol>${sourcesList}${sources.length > 12 ? `<li style="opacity:0.5;">â¦and ${sources.length - 12} more</li>` : ''}</ol></div>` 2743 : ''; 2744 // The stored research JSON keeps the report under `result` (clean) / 2745 // `raw_report` â there's no `summary` field, so the preview was empty. 2746 const summary = (detail.summary || detail.report_summary || detail.result || detail.raw_report || '').toString().trim(); 2747 const summaryHtml = summary 2748 ? `<div class="doclib-research-summary"><div class="doclib-research-section-label">Report</div><div>${markdownModule.mdToHtml ? markdownModule.mdToHtml(summary) : _esc(summary)}</div></div>` 2749 : ''; 2750 preview.innerHTML = 2751 '<div class="doclib-chat-preview-messages">' + 2752 (summaryHtml || sourcesHtml || '<div style="opacity:0.4;font-size:11px;padding:6px 4px;">No preview available</div>') + 2753 (summaryHtml && sourcesHtml ? sourcesHtml : '') + 2754 '</div>' + 2755 '<div class="doclib-chat-preview-actions">' + 2756 '<button class="doclib-chat-delete-btn">' +
2757 '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>' + 2758 'Delete' + 2759 '</button>' + 2760 '<button class="doclib-chat-archive-btn">' + 2761 '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>' + 2762 ((_researchArchivedView || item.archived) ? 'Restore' : 'Archive') + 2763 '</button>' + 2764 // Discuss is hidden in the Archive so the footer matches chat 2765 // (Delete + Restore + Open). 2766 (item.archived ? '' : 2767 '<button class="doclib-chat-discuss-btn">' + 2768 '<svg width="11" height="11" viewB
2768ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>' + 2769 'Discuss' + 2770 '</button>') + 2771 '<button class="doclib-chat-open-btn">' + 2772 '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>' + 2773 'Open' + 2774 '</button>' + 2775 '</div>'; 2776 const discussBtn = preview.querySelector('.doclib-chat-discuss-btn'); 2777 if (discussBtn) discussBtn.addEventListener('click', async (e) => { 2778 e.stopPropagation(); 2779 const _orig = discussBtn.innerHTML; 2780 discussBtn.disabled = true; 2781 discussBtn.textContent = 'Creatingâ¦'; 2782 try { 2783 const _sid = detail.session_id || detail.id || item.id; 2784 const res = await fetch(`${API_BASE}/api/research/spinoff/${_sid}`, { method: 'POST', credentials: 'same-origin' }); 2785 if (!res.ok) { let d = ''; try { d = (await res.json()).detail || ''; } catch {} throw new Error(d || ('HTTP ' + res.status)); } 2786 const payload = await res.json(); 2787 if (window.sessionModule && payload.session_id) { 2788 await window.sessionModule.loadSessions().catch(() => {}); 2789 await window.sessionModule.selectSession(payload.session_id); 2790 } 2791 closeLibrary(); 2792 } catch (err) { 2793 discussBtn.disabled = false; 2794 discussBtn.innerHTML = _orig; 2795 if (uiModule) uiModule.showError('Could not start discussion: ' + (err.message || err)); 2796 } 2797 }); 2798 const openBtn = preview.querySelector('.doclib-chat-open-btn'); 2799 if (openBtn) openBtn.addEventListener('click', (e) => { 2800 e.stopPropagation(); 2801 const a = document.createElement('a'); 2802 a.href = '/api/research/report/' + item.id; 2803 a.target = '_blank'; 2804 a.rel = 'noopener'; 2805 document.body.appendChild(a); 2806 a.click(); 2807 a.remove(); 2808 }); 2809 const delBtn = preview.querySelector('.doclib-chat-delete-btn'); 2810 if (delBtn) delBtn.addEventListener('click', async (e) => { 2811 e.stopPropagation(); 2812 const ok = uiModule && uiModule.styledConfirm 2813 ? await uiModule.styledConfirm('Delete this research report?', { confirmText: 'Delete', danger: true }) 2814 : window.confirm('Delete this research report?'); 2815 if (!ok) return; 2816 try { 2817 const res = await fetch(`${API_BASE}/api/research/${item.id}`, { method: 'DELETE', credentials: 'same-origin' }); 2818 if (!res.ok) throw new Error(await res.text()); 2819 if (item.archived) { 2820 _renderLibArchive(); 2821 } else { 2822 _researchItems = _researchItems.filter(r => r.id !== item.id); 2823 _renderResearchGrid(); 2824 } 2825 } catch (err) { 2826 if (uiModule && uiModule.showError) uiModule.showError('Failed to delete: ' + err.message); 2827 } 2828 }); 2829 const arcBtn = preview.querySelector('.doclib-chat-archive-btn'); 2830 if (arcBtn) arcBtn.addEventListener('click', async (e) => { 2831 e.stopPropagation(); 2832 // From the main Archive tab the item is already archived â Restore and 2833 // refresh the archive. From the Research tab, toggle as before. 2834 const fromArchiveTab = !!item.archived; 2835 const toArchived = fromArchiveTab ? false : !_researchArchivedView; 2836 try { 2837 await fetch(`${API_BASE}/api/research/${item.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' }); 2838 if (fromArchiveTab) { 2839 _renderLibArchive(); 2840 } else { 2841 _researchItems = _researchItems.filter(r => r.id !== item.id); 2842 _renderResearchGrid(); 2843 } 2844 if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored'); 2845 } catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); } 2846 }); 2847 } 2848 2849 function _renderResearchGrid() { 2850 const grid = document.getElementById('doclib-research-grid'); 2851 const stats = document.getElementById('doclib-research-stats'); 2852 if (!grid) return; 2853 const _rsb = document.getElementById('doclib-research-select-btn'); 2854 if (_rsb) { _rsb.classList.toggle('active', _researchSelectMode); _rsb.textContent = _researchSelectMode ? 'Cancel' : 'Select'; } 2855 let items = _researchItems; 2856 if (_researchSearch) { 2857 const s = _researchSearch.toLowerCase(); 2858 items = items.filter(r => (r.query || '').toLowerCase().includes(s)); 2859 } 2860 // Sort 2861 const _rSort = document.getElementById('doclib-research-sort')?.value || 'recent';
2862 if (_rSort === 'recent') items.sort((a, b) => (b.completed_at || 0) - (a.completed_at || 0)); 2863 else if (_rSort === 'oldest') items.sort((a, b) => (a.completed_at || 0) - (b.completed_at || 0)); 2864 else if (_rSort === 'most-sources') items.sort((a, b) => (b.source_count || 0) - (a.source_count || 0)); 2865 else if (_rSort === 'alpha') items.sort((a, b) => (a.query || '').localeCompare(b.query || '')); 2866 if (stats) stats.textContent = items.length + ' research' + (items.length !== 1 ? 'es' : ''); 2867 if (!items.length) { 2868 grid.innerHTML = 2869 '<div class="hwfit-loading" style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;">' + 2870 '<span>No research yet</span>' + 2871 '<span style="opacity:0.7;font-size:11px;">' + 2872 'create one in the <a href="#" data-doclib-open-research style="color:var(--accent,var(--red));text-decoration:underline;">Deep Research</a> tab' + 2873 '</span>' + 2874 '</div>'; 2875 grid.querySelector('[data-doclib-open-research]')?.addEventListener('click', (e) => { 2876 e.preventDefault(); 2877 document.getElementById('rail-research')?.click(); 2878 }); 2879 _appendInlineLoadMore(grid, 0, _researchVisibleLimit, () => {}); 2880 return; 2881 } 2882 const total = items.length; 2883 items = items.slice(0, _researchVisibleLimit); 2884 let html = ''; 2885 for (const r of items) { 2886 const date = r.completed_at ? new Date(r.completed_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : ''; 2887 const time = r.completed_at ? new Date(r.completed_at * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : ''; 2888 const sources = r.source_count || 0; 2889 const duration = r.duration || ''; 2890 const rounds = r.rounds || ''; 2891 const selected = _researchSelected.has(r.id); 2892 const metaBits = []; 2893 if (date) metaBits.push(`${date} ${time}`); 2894 if (sources) metaBits.push(`${sources} sources`); 2895 if (rounds) metaBits.push(`${rounds} rounds`); 2896 if (duration) metaBits.push(`${duration}`); 2897 const metaText = metaBits.join(' \u00B7 '); 2898 html += `<div class="memory-item doclib-chat-row doclib-research-card" data-research-id="${r.id}" style="cursor:pointer;">`; 2899 html += `<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">`; 2900 if (_researchSelectMode) html += `<input type="checkbox" class="memory-select-cb _res-cb" data-rid="${r.id}"${selected ? ' checked' : ''}>`; 2901 html += `<div style="flex:1;min-width:0;">`; 2902 html += `<div class="memory-item-title"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.4;flex-shrink:0;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>${_esc(r.query || 'Untitled Research')}</div>`; 2903 html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">${metaText}</div>`; 2904 html += `</div>`; 2905 if (!_researchSelectMode) html += `<div class="memory-item-actions"><button class="memory-item-btn doclib-research-delete" data-rid="${r.id}" title="Delete"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>`; 2906 html += `</div>`; 2907 html += `<div class="doclib-chat-preview" style="display:none;"></div>`; 2908 html += `</div>`; 2909 } 2910 grid.innerHTML = html; 2911 _maybeCascadeGrid(grid, 'research'); 2912 2913 // Wire checkboxes 2914 grid.querySelectorAll('._res-cb').forEach(cb => { 2915 cb.addEventListener('click', e => e.stopPropagation()); 2916 cb.addEventListener('change', () => { 2917 if (cb.checked) _researchSelected.add(cb.dataset.rid); else _researchSelected.delete(cb.dataset.rid); 2918 _updateResearchCount(); 2919 }); 2920 }); 2921 2922 // Click card â toggle preview (chat-style expand). The menu button 2923 // and Open-report button inside the preview are exempt. 2924 grid.querySelectorAll('.doclib-research-card').forEach(card => { 2925 card.addEventListener('click', (e) => { 2926 if (card._suppressNextClick) { card._suppressNextClick = false; return; } 2927 if (e.target.closest('.doclib-research-delete') || e.target.closest('._res-cb') || e.target.closest('.doclib-chat-open-btn')) return; 2928 const rid = card.dataset.researchId; 2929 if (_researchSelectMode) { 2930 const cb = card.querySelector('._res-cb'); 2931 if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); } 2932 return; 2933 } 2934 const item = _researchItems.find(r => r.id === rid); 2935 if (item) _toggleResearchPreview(card, item); 2936 }); 2937 _attachLongPressMenu(card, '.doclib-research-delete'); 2938 }); 2939 2940 // The action button on each research row opens the actions menu 2941 // (Open report, Delete) â chat-style â¢â¢â¢ menu. 2942 grid.querySelectorAll('.doclib-research-delete').forEach(btn => { 2943 btn.addEventListener('click', (e) => { 2944 e.stopPropagation(); 2945 const rid = btn.dataset.rid; 2946 _showLibDropdown(btn, [ 2947 { label: 'Open', action: () => { 2948 const a = document.createElement('a'); 2949 a.href = '/api/research/report/' + rid; 2950 a.target = '_blank'; 2951 a.rel = 'noopener'; 2952 document.body.appendChild(a); 2953 a.click(); 2954 a.remove(); 2955 } },
2956 { label: _researchArchivedView ? 'Restore' : 'Archive', action: async () => { 2957 const toArchived = !_researchArchivedView; 2958 const card = btn.closest('.doclib-research-card'); 2959 if (card) { card.style.transition = 'opacity 0.25s, transform 0.25s'; card.style.opacity = '0'; card.style.transform = 'scale(0.95)'; } 2960 try { await fetch('/api/research/' + rid + '/archive?archived=' + toArchived, { method: 'POST', credentials: 'same-origin' }); } catch {} 2961 await new Promise(r => setTimeout(r, 200)); 2962 _researchItems = _researchItems.filter(r => r.id !== rid); 2963 _renderResearchGrid(); 2964 if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored'); 2965 } }, 2966 { label: 'Delete', danger: true, action: async () => { 2967 if (!await window.styledConfirm('Delete this research?', { confirmText: 'Delete', danger: true })) return; 2968 const card = btn.closest('.doclib-research-card'); 2969 if (card) { 2970 card.style.transition = 'opacity 0.25s, transform 0.25s'; 2971 card.style.opacity = '0'; 2972 card.style.transform = 'scale(0.95)'; 2973 } 2974 await new Promise(r => setTimeout(r, 250)); 2975 await fetch('/api/research/' + rid, { method: 'DELETE', credentials: 'same-origin' }); 2976 _researchItems = _researchItems.filter(r => r.id !== rid); 2977 _renderResearchGrid(); 2978 } }, 2979 ], { onSelect: () => { 2980 _researchSelectMode = true; 2981 _researchSelected.add(rid); 2982 document.getElementById('doclib-research-bulk')?.classList.remove('hidden'); 2983 _renderResearchGrid(); 2984 } }); 2985 }); 2986 }); 2987 _appendInlineLoadMore(grid, total, _researchVisibleLimit, () => { 2988 _researchVisibleLimit += _LIB_PAGE_SIZE; 2989 _renderResearchGrid(); 2990 }); 2991 } 2992 2993 // Research sort + search 2994 const researchSortEl = document.getElementById('doclib-research-sort'); 2995 if (researchSortEl) researchSortEl.addEventListener('change', () => _renderResearchGrid()); 2996 const researchSearchEl = document.getElementById('doclib-research-search'); 2997 if (researchSearchEl) { 2998 researchSearchEl.addEventListener('input', () => { 2999 _researchSearch = researchSearchEl.value.trim(); 3000 _renderResearchGrid(); 3001 }); 3002 } 3003 3004 function _updateResearchCount() { 3005 const el = document.getElementById('doclib-research-selected-count'); 3006 if (el) el.textContent = _researchSelected.size + ' Selected'; 3007 const arc = document.getElementById('doclib-research-bulk-archive'); 3008 if (arc) arc.textContent = _researchArchivedView ? 'Restore' : 'Archive'; 3009 } 3010 3011 // Research select mode 3012 document.getElementById('doclib-research-select-btn')?.addEventListener('click', () => { 3013 _researchSelectMode = !_researchSelectMode; 3014 _researchSelected.clear(); 3015 document.getElementById('doclib-research-bulk').classList.toggle('hidden', !_researchSelectMode); 3016 _renderResearchGrid(); 3017 }); 3018 3019 // Research tidy â delete reports that came back empty (no sources, or 3020 // empty report body). Matches the Chats tidy whirlpool/borderless pattern 3021 // and skips confirmation per user request. 3022 document.getElementById('doclib-research-tidy-btn')?.addEventListener('click', async (e) => { 3023 const btn = e.currentTarget; 3024 const origHTML = btn.innerHTML; 3025 btn.disabled = true; 3026 btn.classList.add('spinning'); 3027 btn.textContent = ''; 3028 const sp = spinnerModule.create('', 'clean', 'whirlpool'); 3029 const el = sp.createElement(); 3030 el.style.position = 'relative'; 3031 el.style.top = '1px'; 3032 btn.appendChild(el); 3033 sp.start(); 3034 try { 3035 const candidates = []; 3036 const needFetch = []; 3037 for (const r of _researchItems) { 3038 if ((r.source_count || 0) === 0) candidates.push(r); 3039 else needFetch.push(r); 3040 } 3041 const results = await Promise.all(needFetch.map(async r => { 3042 try { 3043 const res = await fetch('/api/research/detail/' + r.id, { credentials: 'same-origin' }); 3044 if (!res.ok) return null; 3045 const d = await res.json(); 3046 // Backend JSON uses `result` (rendered) or `raw_report` (raw md). 3047 // If neither exists or both are tiny, treat as empty. 3048 const body = (d.result || d.raw_report || '').trim(); 3049 return body.length < 200 ? r : null; 3050 } catch { return null; } 3051 })); 3052 for (const r of results) if (r) candidates.push(r); 3053 if (candidates.length === 0) { 3054 if (uiModule) uiModule.showToast('Nothing to tidy'); 3055 return; 3056 } 3057 await Promise.all(candidates.map(r => fetch('/api/research/' + r.id, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {}))); 3058 const ids = new Set(candidates.map(r => r.id)); 3059 _researchItems = _researchItems.filter(r => !ids.has(r.id)); 3060 _renderResearchGrid(); 3061 if (uiModule) uiModule.showToast('Deleted ' + candidates.length); 3062 } finally { 3063 sp.stop(); 3064 btn.disabled = false;
3065 btn.classList.remove('spinning'); 3066 btn.innerHTML = origHTML; 3067 } 3068 }); 3069 document.getElementById('doclib-research-archived-btn')?.addEventListener('click', (e) => { 3070 _researchArchivedView = !_researchArchivedView; 3071 e.currentTarget.classList.toggle('active', _researchArchivedView); 3072 e.currentTarget.title = _researchArchivedView ? 'Show active research' : 'Show archived research'; 3073 if (_researchSelectMode) { _researchSelectMode = false; _researchSelected.clear(); document.getElementById('doclib-research-bulk').classList.add('hidden'); } 3074 _renderLibResearch(); 3075 }); 3076 document.getElementById('doclib-research-bulk-cancel')?.addEventListener('click', () => { 3077 _researchSelectMode = false; 3078 _researchSelected.clear(); 3079 document.getElementById('doclib-research-bulk').classList.add('hidden'); 3080 _renderResearchGrid(); 3081 }); 3082 3083 // Research select all 3084 document.getElementById('doclib-research-select-all')?.addEventListener('change', () => { 3085 const allCb = document.getElementById('doclib-research-select-all'); 3086 const newState = allCb?.checked; 3087 _researchItems.forEach(r => { if (newState) _researchSelected.add(r.id); else _researchSelected.delete(r.id); }); 3088 _updateResearchCount(); 3089 _renderResearchGrid(); 3090 }); 3091 3092 // Research bulk delete 3093 document.getElementById('doclib-research-bulk-delete')?.addEventListener('click', async () => { 3094 const count = _researchSelected.size; 3095 if (!count) return; 3096 if (!await window.styledConfirm(`Delete ${count} research report${count > 1 ? 's' : ''} permanently?`, { confirmText: 'Delete', danger: true })) return; 3097 const grid = document.getElementById('doclib-research-grid'); 3098 if (grid) { 3099 grid.querySelectorAll('.doclib-research-card').forEach(card => { 3100 if (_researchSelected.has(card.dataset.researchId)) { 3101 card.style.transition = 'opacity 0.25s, transform 0.25s'; 3102 card.style.opacity = '0'; 3103 card.style.transform = 'scale(0.95)'; 3104 } 3105 }); 3106 } 3107 await new Promise(r => setTimeout(r, 250)); 3108 await Promise.all([..._researchSelected].map(rid => fetch('/api/research/' + rid, { method: 'DELETE', credentials: 'same-origin' }))); 3109 _researchItems = _researchItems.filter(r => !_researchSelected.has(r.id)); 3110 _researchSelected.clear(); 3111 _researchSelectMode = false; 3112 document.getElementById('doclib-research-bulk').classList.add('hidden'); 3113 _renderResearchGrid(); 3114 }); 3115 3116 // Research bulk archive / restore 3117 document.getElementById('doclib-research-bulk-archive')?.addEventListener('click', async () => { 3118 const count = _researchSelected.size; 3119 if (!count) return; 3120 const toArchived = !_researchArchivedView; 3121 const grid = document.getElementById('doclib-research-grid'); 3122 if (grid) { 3123 grid.querySelectorAll('.doclib-research-card').forEach(card => { 3124 if (_researchSelected.has(card.dataset.researchId)) { 3125 card.style.transition = 'opacity 0.25s, transform 0.25s'; 3126 card.style.opacity = '0'; 3127 card.style.transform = 'scale(0.95)'; 3128 } 3129 }); 3130 } 3131 await new Promise(r => setTimeout(r, 250)); 3132 await Promise.all([..._researchSelected].map(rid => fetch('/api/research/' + rid + '/archive?archived=' + toArchived, { method: 'POST', credentials: 'same-origin' }))); 3133 _researchItems = _researchItems.filter(r => !_researchSelected.has(r.id)); 3134 _researchSelected.clear(); 3135 _researchSelectMode = false; 3136 document.getElementById('doclib-research-bulk').classList.add('hidden'); 3137 _renderResearchGrid(); 3138 if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored'); 3139 }); 3140 3141 // Shared dropdown for chats/archive menus â defined at module scope below 3142 // (was here originally; hoisted so libraryCreateCard's mobile kebab 3143 // handler â which lives outside openLibrary's closure â can call it). 3144 3145 function _relTime(iso) { 3146 if (!iso) return ''; 3147 const diff = Date.now() - new Date(iso).getTime(); 3148 const mins = Math.floor(diff / 60000); 3149 if (mins < 60) return mins + 'm ago'; 3150 const hrs = Math.floor(mins / 60); 3151 if (hrs < 24) return hrs + 'h ago'; 3152 const days = Math.floor(hrs / 24); 3153 if (days < 30) return days + 'd ago'; 3154 return new Date(iso).toLocaleDateString(); 3155 } 3156 3157 // Switch to the initial tab. Always call this â even when the 3158 // default ('documents') matches â so the modal header's icon + label 3159 // sync from "Library" to the active sub-tab on first open. 3160 _switchLibTab(_activeLibTab); 3161 3162 const searchInput = document.getElementById('doclib-search'); 3163 searchInput.addEventListener('input', () => { 3164 clearTimeout(_librarySearchDebounce); 3165 _librarySearchDebounce = setTimeout(() => { 3166 _librarySearch = searchInput.value.trim(); 3167 libraryFetch(false); 3168 }, 300); 3169 }); 3170 3171 document.getElementById('doclib-sort').addEventListener('change', (e) => { 3172 _librarySort = e.target.value; 3173 libraryFetch(false); 3174 }); 3175 3176 document.getElementById('doclib-load-more').addEventListener('click', () => { 3177 _libraryOffset = _libraryDocs.length; 3178 libraryFetch(true); 3179 }); 3180 3181 // Show "Load more" only when scrolled near bottom 3182 const grid = document.getElementById('doclib-grid'); 3183 if (grid) { 3184 grid.addEventListener('scroll', () => libraryRenderLoadMore()); 3185 // Auto-fill on resize (fullscreen toggle, window resize, sidebar 3186 // toggle): re-run the load-more check so newly-revealed empty 3187 // space below the last card pulls in the next page automatically. 3188 if (typeof ResizeObserver !== 'undefined') { 3189 new ResizeObserver(() => libraryRenderLoadMore()).observe(grid); 3190 } 3191 } 3192 3193 // Wire file import button 3194 const importFileBtn = document.getElementById('doclib-import-file-btn'); 3195 const fileInput = document.getElementById('doclib-file-input'); 3196 if (importFileBtn && fileInput) { 3197 importFileBtn.addEventListener('click', () => fileInput.click()); 3198 fileInput.addEventListener('change', async () => { 3199 if (fileInput.files.length === 0) return; 3200 const files = Array.from(fileInput.files); 3201 fileInput.value = ''; 3202 // Swap the import icon for a whirlpool while files upload. 3203 const _orig = importFileBtn.innerHTML; 3204 importFileBtn.disabled = true; 3205 let _sp = null; 3206 try { 3207 _sp = spinnerModule.createWhirlpool(12); 3208 _sp.element.style.cssText = 'width:12px;height:12px;margin:0 4px 0 0;display:inline-block;vertical-align:middle;position:relative;top:-2px;'; 3209 importFileBtn.innerHTML = ''; 3210 importFileBtn.appendChild(_sp.element); 3211 importFileBtn.appendChild(document.createTextNode('Import')); 3212 } catch {} 3213 try {
3214 await libraryImportFiles(files); 3215 } finally { 3216 try { _sp && _sp.stop(); } catch {} 3217 importFileBtn.innerHTML = _orig; 3218 importFileBtn.disabled = false; 3219 } 3220 }); 3221 } 3222 3223 // Create button â new blank document 3224 const createBtn = document.getElementById('doclib-create-btn'); 3225 if (createBtn) { 3226 createBtn.addEventListener('click', async () => { 3227 // Create a new session, then create a blank document in it 3228 try { 3229 const sRes = await fetch('/api/session', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Untitled Document' }) }); 3230 const sData = await sRes.json(); 3231 const sessionId = sData.session_id; 3232 await _createDocument(sessionId); 3233 // Close library and open the new session 3234 closeLibrary(); 3235 if (window.sessionsModule) window.sessionsModule.loadSession(sessionId); 3236 setTimeout(() => _openPanel(), 300); 3237 } catch (e) { 3238 console.error('Failed to create document:', e); 3239 if (uiModule) uiModule.showError('Failed to create document'); 3240 } 3241 }); 3242 } 3243 3244 // Archived toggle â flip the Documents list between active and archived. 3245 const archivedBtn = document.getElementById('doclib-archived-btn'); 3246 if (archivedBtn) archivedBtn.addEventListener('click', () => { 3247 _libraryArchivedView = !_libraryArchivedView; 3248 archivedBtn.classList.toggle('active', _libraryArchivedView); 3249 archivedBtn.title = _libraryArchivedView ? 'Show active documents' : 'Show archived documents'; 3250 if (_librarySelectMode) libraryExitSelectMode(); 3251 libraryFetch(false); 3252 }); 3253 3254 // Tidy button â remove empty/broken documents 3255 const tidyBtn = document.getElementById('doclib-tidy-btn'); 3256 if (tidyBtn) tidyBtn.addEventListener('click', async () => { 3257 tidyBtn.disabled = true; 3258 tidyBtn.classList.add('spinning'); 3259 const origHTML = tidyBtn.innerHTML; 3260 tidyBtn.textContent = ''; 3261 const spinner = spinnerModule.create('', 'clean', 'whirlpool'); 3262 const _spEl = spinner.createElement(); 3263 // Optical alignment: whirlpool reads 1px high inside the button. 3264 _spEl.style.position = 'relative'; 3265 _spEl.style.top = '1px'; 3266 tidyBtn.appendChild(_spEl); 3267 spinner.start(); 3268 3269 let totalDeleted = 0; 3270 let totalFixed = 0; 3271 let aiMessage = ''; 3272 try { 3273 // Phase 1: regex tidy (empty/broken docs) 3274 const [res1] = await Promise.all([ 3275 fetch(`${API_BASE}/api/documents/tidy`, { method: 'POST' }), 3276 new Promise(r => setTimeout(r, 600)), 3277 ]); 3278 if (res1.ok) { 3279 const d1 = await res1.json(); 3280 totalDeleted += d1.deleted || 0; 3281 totalFixed += d1.fixed_titles || 0; 3282 } 3283 3284 // Phase 2: AI tidy (junk/test detection) 3285 try { 3286 const res2 = await fetch(`${API_BASE}/api/documents/ai-tidy`, { method: 'POST' }); 3287 if (res2.ok) { 3288 const d2 = await res2.json(); 3289 totalDeleted += d2.deleted || 0; 3290 if (d2.message) aiMessage = d2.message; 3291 } 3292 } catch (_) { /* AI tidy is optional */ } 3293 3294 spinner.destroy(); 3295 3296 if (totalDeleted === 0 && totalFixed === 0) { 3297 tidyBtn.innerHTML = '<span style="opacity:0.7">Already tidy</span>'; 3298 } else { 3299 const msg = aiMessage || `Removed ${totalDeleted} document${totalDeleted !== 1 ? 's' : ''}`; 3300 if (uiModule) uiModule.showToast(msg); 3301 libraryFetch(false); 3302 } 3303 setTimeout(() => { tidyBtn.innerHTML = origHTML; tidyBtn.disabled = false; tidyBtn.classList.remove('spinning'); }, 1500); 3304 } catch (e) { 3305 spinner.destroy(); 3306 console.error('Document tidy failed:', e); 3307 if (uiModule) uiModule.showToast('Tidy failed'); 3308 tidyBtn.disabled = false; 3309 tidyBtn.classList.remove('spinning'); 3310 tidyBtn.innerHTML = origHTML; 3311 } 3312 }); 3313 3314 // Select mode 3315 const selectBtn = document.getElementById('doclib-select-btn'); 3316 if (selectBtn) selectBtn.addEventListener('click', () => { 3317 if (_librarySelectMode) libraryExitSelectMode(); 3318 else libraryEnterSelectMode(); 3319 }); 3320 3321 const selectAll = document.getElementById('doclib-select-all'); 3322 if (selectAll) selectAll.addEventListener('change', libraryToggleSelectAll); 3323
3324 // Click anywhere in the bulk bar "All" label or count area to toggle select-all 3325 const bulkCheckLabel = modal.querySelector('.memory-bulk-check-all'); 3326 if (bulkCheckLabel) { 3327 bulkCheckLabel.addEventListener('click', (e) => { 3328 if (e.target === selectAll) return; // let native checkbox handle it 3329 e.preventDefault(); 3330 selectAll.checked = !selectAll.checked; 3331 libraryToggleSelectAll(); 3332 }); 3333 } 3334 const selectedCountEl = document.getElementById('doclib-selected-count'); 3335 if (selectedCountEl) { 3336 selectedCountEl.style.cursor = 'pointer'; 3337 selectedCountEl.addEventListener('click', () => { 3338 selectAll.checked = !selectAll.checked; 3339 libraryToggleSelectAll(); 3340 }); 3341 } 3342 3343 const bulkActionsBtn = document.getElementById('doclib-bulk-actions'); 3344 if (bulkActionsBtn) bulkActionsBtn.addEventListener('click', (e) => { 3345 e.stopPropagation(); 3346 if (_librarySelectedIds.size === 0) { 3347 if (uiModule) uiModule.showToast('Select documents first'); 3348 return; 3349 } 3350 _showLibDropdown(e.currentTarget, [ 3351 { label: _libraryArchivedView ? 'Restore' : 'Archive', icon: _libraryArchivedView ? 'restore' : 'archive', action: libraryBulkArchive }, 3352 { label: 'Clone', icon: 'clone', action: libraryBulkClone }, 3353 { label: 'Export', icon: 'open', action: libraryBulkExport }, 3354 { label: 'Delete', icon: 'delete', danger: true, action: libraryBulkDelete }, 3355 ], { onCancel: libraryExitSelectMode }); 3356 }); 3357 3358 const bulkCancelBtn = document.getElementById('doclib-bulk-cancel'); 3359 if (bulkCancelBtn) bulkCancelBtn.addEventListener('click', libraryExitSelectMode); 3360 3361 // Close on click outside modal content 3362 modal.addEventListener('click', (e) => { 3363 if (uiModule.isTouchInsideModal()) return; 3364 if (e.target === modal) closeLibrary(); 3365 }); 3366 3367 // Escape key 3368 _libraryEscHandler = (e) => { 3369 if (e.key === 'Escape') { 3370 // Collapse expanded card first, then close modal on second Escape 3371 const expanded = document.querySelector('#doclib-grid .doclib-card-expanded'); 3372 if (expanded) { 3373 _collapseExpandedCard(expanded); 3374 } else { 3375 closeLibrary(); 3376 } 3377 } 3378 }; 3379 document.addEventListener('keydown', _libraryEscHandler); 3380 3381 // Toggle active on tool button 3382 const btn = document.getElementById('tool-doclib-btn'); 3383 if (btn) btn.classList.add('active'); 3384 3385 libraryFetch(false); 3386 if (window.innerWidth >= 768) searchInput.focus(); 3387 } 3388 3389 export function closeLibrary() { 3390 if (!_libraryOpen) return; 3391 _libraryOpen = false; 3392 _librarySelectMode = false; 3393 _librarySelectedIds.clear(); 3394 _libraryImportMode = false; 3395 clearTimeout(_librarySearchDebounce); 3396 3397 const modal = document.getElementById('doclib-modal'); 3398 if (modal) { 3399 const content = modal.querySelector('.modal-content, .doclib-modal-content'); 3400 if (content) { 3401 content.classList.add('modal-closing'); 3402 content.addEventListener('animationend', () => modal.remove(), { once: true }); 3403 setTimeout(() => { if (modal.parentElement) modal.remove(); }, 250); 3404 } else { 3405 modal.remove(); 3406 } 3407 } 3408 3409 if (_libraryEscHandler) { 3410 document.removeEventListener('keydown', _libraryEscHandler); 3411 _libraryEscHandler = null; 3412 } 3413 3414 const btn = document.getElementById('tool-doclib-btn'); 3415 if (btn) btn.classList.remove('active'); 3416 } 3417 3418 export function isLibraryOpen() { 3419 return _libraryOpen; 3420 }
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.