PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/documentLibrary.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:52 UTC 184,833 bytes, 3,420 lines download raw bytes

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

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.