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