// static/js/documentLibrary.js /** * Document Library — modal with Chats / Documents / Research / Archive tabs. * Extracted from document.js to reduce file size. */ import uiModule from './ui.js'; import sessionModule from './sessions.js'; import spinnerModule from './spinner.js'; import markdownModule from './markdown.js'; import { makeWindowDraggable } from './windowDrag.js'; import { langIcon } from './langIcons.js'; import { registerMenuDismiss, dismissOrRemove } from './escMenuStack.js'; // ── Injected references from documentModule ── let API_BASE = ''; let _esc; // HTML-escape function let _getDocs; // () => Map of open docs let _isOpenFn; // () => boolean — is doc panel open let _createDocument; let _loadDocument; let _switchToDoc; let _openPanel; let _addDocToTabs; let _syncDocIndicator; export function initLibrary(config) { API_BASE = config.apiBase; _esc = config.esc; _getDocs = config.getDocs; _isOpenFn = config.isOpen; _createDocument = config.createDocument; _loadDocument = config.loadDocument; _switchToDoc = config.switchToDoc; _openPanel = config.openPanel; _addDocToTabs = config.addDocToTabs; _syncDocIndicator = config.syncDocIndicator; } // ── Library state ── let _libraryOpen = false; // Track which tabs have already played their domino-in cascade so we only // animate the *first* time content loads per page session — tab swaps and // re-renders after that are instant. const _libraryCascadedTabs = new Set(); function _maybeCascadeGrid(grid, tabKey) { if (!grid || !tabKey || _libraryCascadedTabs.has(tabKey)) return; _libraryCascadedTabs.add(tabKey); grid.classList.add('doclib-just-opened'); setTimeout(() => grid.classList.remove('doclib-just-opened'), 900); } let _libraryDocs = []; let _libraryTotal = 0; let _libraryOffset = 0; let _docsVisibleLimit = 20; // chunked reveal (matches the Chats tab's 20) let _libraryLanguages = {}; let _librarySessionCount = 0; let _libraryActiveLanguage = null; let _librarySort = 'recent'; let _librarySearch = ''; let _librarySearchDebounce = null; // Highlight the active search terms inside a plain string. Escapes first, // then wraps each whitespace-separated term in . Multi-term, matching // the backend's per-term search, so every word that matched is marked. function _hlSearch(text) { const esc = _esc(text || ''); const q = (_librarySearch || '').trim(); if (!q) return esc; const toks = [...new Set(q.split(/\s+/).filter(Boolean))] .sort((a, b) => b.length - a.length) // prefer longer matches .map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); if (!toks.length) return esc; try { return esc.replace(new RegExp(`(${toks.join('|')})`, 'gi'), '$1'); } catch { return esc; } } let _libraryEscHandler = null; let _librarySelectMode = false; let _librarySelectedIds = new Set(); let _libraryImportMode = false; let _libScrollBound = false; // infinite-scroll listener attached once let _libraryArchivedView = false; // Documents tab showing archived docs? // ---- Library animation helpers ---- /** Collapse an expanded card */ function _collapseExpandedCard(card) { const grid = card.closest('.doclib-grid'); const instant = card?.dataset?.spaceToggle === '1'; card.classList.remove('doclib-card-expanded'); // Release the height lock so grid returns to natural size if (grid) { grid.style.minHeight = ''; grid.style.maxHeight = ''; } const reader = card.querySelector('.doclib-card-reader'); if (reader) reader.remove(); // Fade siblings back in if (grid && !instant) { const siblings = [...grid.querySelectorAll('.doclib-card')].filter(c => c !== card); siblings.forEach(s => { s.style.opacity = '0'; }); requestAnimationFrame(() => { siblings.forEach(s => { s.style.transition = 'opacity 0.15s ease'; s.style.opacity = '1'; }); setTimeout(() => { siblings.forEach(s => { s.style.transition = ''; s.style.opacity = ''; }); }, 200); }); } } // Fetch a chat's full history and serialize as plain-text transcript, // then write to the clipboard. Same User: / Assistant: format the chat // header's "Copy Chat" button uses, but works for any session ID — the // library doesn't need the chat to be loaded in the UI first. async function _copyChatById(sessionId) { try { const res = await fetch(`${API_BASE}/api/history/${sessionId}`, { credentials: 'same-origin' }); if (!res.ok) throw new Error(res.statusText); const data = await res.json(); const history = Array.isArray(data) ? data : (data.history || []); const lines = []; for (const m of history) { if (m.role !== 'user' && m.role !== 'assistant') continue; const label = m.role === 'user' ? 'User' : 'Assistant'; const body = (m.content || '') .replace(/[\s\S]*?<\/think>/g, '') .replace(/[\s\S]*$/, '') .trim(); if (body) lines.push(`${label}: ${body}`); } const text = lines.join('\n\n'); if (uiModule && uiModule.copyToClipboard) { await uiModule.copyToClipboard(text); } else { await navigator.clipboard.writeText(text); } } catch (err) { if (uiModule && uiModule.showError) uiModule.showError('Failed to copy chat'); } } // Long-press a list card to open its actions menu. `menuSelector` resolves // the existing ••• button on the card; on hold we trigger its click so the // dropdown opens in its usual spot. Moved finger >10px or release before // 500ms cancels. function _attachLongPressMenu(card, menuSelector) { let hold = null; let start = null; const cancel = () => { if (hold) { clearTimeout(hold); hold = null; } start = null; }; card.addEventListener('pointerdown', (e) => { if (e.target.closest(menuSelector + ', .memory-select-cb, button')) return; start = { x: e.clientX, y: e.clientY }; hold = setTimeout(() => { hold = null; card._suppressNextClick = true; setTimeout(() => { card._suppressNextClick = false; }, 400); if (navigator.vibrate) try { navigator.vibrate(15); } catch {} const btn = card.querySelector(menuSelector); if (btn) btn.click(); }, 500); }); card.addEventListener('pointermove', (e) => { if (!start) return; if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 10) cancel(); }); card.addEventListener('pointerup', cancel); card.addEventListener('pointercancel', cancel); } // Inline icons used by the chats/archive/research dropdown rows. Match the // ones used by the documents-tab card menu so the visual language stays // consistent across tabs. const _LIB_DD_ICONS = { open: '', archive: '', restore: '', delete: '', clone: '', copy: '', }; function _showLibDropdown(anchor, items, opts) { opts = opts || {}; document.querySelectorAll('._lib-dd').forEach(dismissOrRemove); const dd = document.createElement('div'); dd.className = 'dropdown session-dropdown-menu _lib-dd'; for (const item of items) { const row = document.createElement('div'); row.className = 'dropdown-item-compact' + (item.danger ? ' dropdown-item-danger' : ''); const iconKey = item.icon || item.label.toLowerCase(); const iconSvg = _LIB_DD_ICONS[iconKey] || ''; row.innerHTML = (iconSvg ? '' + iconSvg + '' : '') + '' + item.label + ''; row.addEventListener('click', (e) => { e.stopPropagation(); teardown(); item.action(); }); dd.appendChild(row); } if (typeof opts.onSelect === 'function') { const sel = document.createElement('div'); sel.className = 'dropdown-item-compact'; sel.innerHTML = '' + 'Select'; sel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); opts.onSelect(); }); dd.appendChild(sel); } const cancel = document.createElement('div'); cancel.className = 'dropdown-item-compact dropdown-cancel-mobile'; cancel.innerHTML = '' + 'Cancel'; cancel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); if (typeof opts.onCancel === 'function') opts.onCancel(); }); dd.appendChild(cancel); document.body.appendChild(dd); const rect = anchor.getBoundingClientRect(); dd.style.right = (window.innerWidth - rect.right) + 'px'; dd.style.top = (rect.bottom + 2) + 'px'; dd.style.display = 'block'; dd.style.zIndex = '100000'; requestAnimationFrame(() => { const mr = dd.getBoundingClientRect(); if (mr.bottom > window.innerHeight - 8) { dd.style.top = (rect.top - mr.height - 2) + 'px'; } if (mr.left < 8) { dd.style.left = '8px'; dd.style.right = 'auto'; } }); // Single idempotent teardown shared by every dismissal path (item click, // outside click, swipe, and the Escape arbiter via registerMenuDismiss). let _unreg = () => {}; const teardown = () => { _unreg(); _unreg = () => {}; document.removeEventListener('click', close); dd.remove(); }; const close = (e) => { if (!dd.contains(e.target)) teardown(); }; setTimeout(() => document.addEventListener('click', close), 0); _unreg = registerMenuDismiss(teardown); dd._dismiss = teardown; // let bulk removers (reopen sweep) tear down cleanly // Swipe-down-to-dismiss (mobile). Mirrors the bottom-sheet feel — drag the // popup down and release past the threshold to close. Below threshold, // snap back. Vertical-only; horizontal flicks fall through to scrolling. let _swipeStart = null; let _swipeDy = 0; dd.addEventListener('touchstart', (e) => { if (e.touches.length !== 1) return; _swipeStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }; _swipeDy = 0; dd.style.transition = ''; }, { passive: true }); dd.addEventListener('touchmove', (e) => { if (!_swipeStart || e.touches.length !== 1) return; const dx = e.touches[0].clientX - _swipeStart.x; const dy = e.touches[0].clientY - _swipeStart.y; if (Math.abs(dy) < Math.abs(dx)) { _swipeStart = null; return; } if (dy > 0) { _swipeDy = dy; dd.style.transform = 'translateY(' + dy + 'px)'; dd.style.opacity = String(Math.max(0.3, 1 - dy / 240)); } }, { passive: true }); dd.addEventListener('touchend', () => { if (!_swipeStart) return; _swipeStart = null; if (_swipeDy > 60) { dd.style.transition = 'transform 0.15s ease, opacity 0.15s ease'; dd.style.transform = 'translateY(120px)'; dd.style.opacity = '0'; // Unregister + drop the outside-click listener now; defer the DOM // removal so the slide-out animation can play. _unreg(); _unreg = () => {}; document.removeEventListener('click', close); setTimeout(() => dd.remove(), 160); } else { dd.style.transition = 'transform 0.18s ease, opacity 0.18s ease'; dd.style.transform = ''; dd.style.opacity = ''; } }); } // ---- Document Library ---- function libraryRelativeTime(isoString) { if (!isoString) return ''; const now = Date.now(); const then = new Date(isoString).getTime(); const diffS = Math.floor((now - then) / 1000); if (diffS < 60) return 'just now'; const diffM = Math.floor(diffS / 60); if (diffM < 60) return diffM + 'm ago'; const diffH = Math.floor(diffM / 60); if (diffH < 24) return diffH + 'h ago'; const diffD = Math.floor(diffH / 24); if (diffD === 1) return 'yesterday'; if (diffD < 14) return diffD + 'd ago'; const diffW = Math.floor(diffD / 7); if (diffW < 8) return diffW + 'w ago'; return new Date(isoString).toLocaleDateString(); } async function libraryFetch(append) { if (!append) _libraryOffset = 0; // Bump page size to the backend max (50) so fullscreen doesn't leave // empty space below the loaded rows — same idea as emailLibrary's // limit=100, but documents_library validates `le=50` so we have to // cap at that. Auto-fill loop below picks up any remaining gap. const params = new URLSearchParams({ sort: _librarySort, offset: String(_libraryOffset), limit: '50', }); if (_librarySearch) params.set('search', _librarySearch); if (_libraryActiveLanguage) params.set('language', _libraryActiveLanguage); if (_libraryArchivedView) params.set('archived', 'true'); try { const res = await fetch(`${API_BASE}/api/documents/library?${params}`); if (!res.ok) throw new Error(res.statusText); const data = await res.json(); if (append) { _libraryDocs = _libraryDocs.concat(data.documents); } else { _libraryDocs = data.documents; _docsVisibleLimit = 20; // reset chunk on a fresh load / search / sort } _libraryTotal = data.total; _libraryLanguages = data.languages; _librarySessionCount = data.session_count; libraryRenderStats(); libraryRenderLangChips(); libraryRenderGrid(); libraryRenderLoadMore(); } catch (e) { console.error('Library fetch error:', e); } } function libraryRenderStats() { const el = document.getElementById('doclib-stats'); if (!el) return; const totalAll = Object.values(_libraryLanguages).reduce((a, b) => a + b, 0); if (_librarySearch || _libraryActiveLanguage) { el.textContent = `${_libraryTotal} of ${totalAll} document${totalAll !== 1 ? 's' : ''}`; } else { el.textContent = `${totalAll} document${totalAll !== 1 ? 's' : ''}`; } } function libraryRenderLangChips() { const wrap = document.getElementById('doclib-chips'); if (!wrap) return; // Remove only language chip buttons, keep sort/select elements wrap.querySelectorAll('.memory-cat-chip').forEach(c => c.remove()); const totalAll = Object.values(_libraryLanguages).reduce((a, b) => a + b, 0); // Hide the "all (0)" chip + lang chips entirely when there are no docs. if (totalAll === 0) return; const allChip = document.createElement('button'); allChip.className = 'memory-cat-chip' + (!_libraryActiveLanguage ? ' active' : ''); allChip.textContent = `all (${totalAll})`; allChip.addEventListener('click', () => { if (_librarySelectMode) { _libraryDocs.forEach(d => _librarySelectedIds.add(d.id)); libraryUpdateBulkCount(); const selectAllEl = document.getElementById('doclib-select-all'); if (selectAllEl) selectAllEl.checked = true; libraryRenderGrid(); return; } _libraryActiveLanguage = null; libraryFetch(false); }); wrap.appendChild(allChip); const sorted = Object.entries(_libraryLanguages).sort((a, b) => b[1] - a[1]); for (const [lang, count] of sorted) { const chip = document.createElement('button'); chip.className = 'memory-cat-chip' + (_libraryActiveLanguage === lang ? ' active' : ''); chip.textContent = `${lang} (${count})`; chip.addEventListener('click', () => { _libraryActiveLanguage = lang; libraryFetch(false); }); wrap.appendChild(chip); } } function libraryRemoveDocumentFromState(docId) { const removed = _libraryDocs.find(d => String(d.id) === String(docId)); _libraryDocs = _libraryDocs.filter(d => String(d.id) !== String(docId)); _librarySelectedIds.delete(docId); _libraryTotal = Math.max(0, _libraryTotal - 1); const lang = removed && (removed.language || 'text'); if (lang && Object.prototype.hasOwnProperty.call(_libraryLanguages, lang)) { const next = Math.max(0, Number(_libraryLanguages[lang] || 0) - 1); if (next > 0) { _libraryLanguages[lang] = next; } else { delete _libraryLanguages[lang]; } } libraryRenderStats(); libraryRenderLangChips(); libraryUpdateBulkCount(); } function libraryRenderGrid() { const grid = document.getElementById('doclib-grid'); if (!grid) return; // An open card menu is mounted on (to escape overflow clipping), so // clearing the grid would orphan it; dismiss it first so its listener + // Escape-stack entry go too. document.querySelectorAll('.doclib-card-dropdown').forEach(dismissOrRemove); grid.innerHTML = ''; // Drop any previous inline load-more — regenerated below alongside the list. if (grid.parentElement) grid.parentElement.querySelectorAll(':scope > .doclib-inline-load-more').forEach(b => b.remove()); if (_libraryDocs.length === 0) { if (_librarySearch || _libraryActiveLanguage) { grid.innerHTML = '
No documents match your search.
'; } else { const _impIco = ''; grid.innerHTML = '
' + 'No documents yet' + '' + 'Import' + _impIco + '' + ' · or create one in a session' + '' + '
'; grid.querySelector('[data-doclib-import]')?.addEventListener('click', (e) => { e.preventDefault(); document.getElementById('doclib-import-file-btn')?.click(); }); } return; } _maybeCascadeGrid(grid, 'documents'); // Reveal in 20-at-a-time chunks (matches the Chats tab). The legacy // server-pagination button is suppressed in libraryRenderLoadMore; this // inline button is the single control. const shown = _libraryDocs.slice(0, _docsVisibleLimit); for (const doc of shown) { grid.appendChild(libraryCreateCard(doc)); } // Show a "Load more" while either more loaded docs remain to reveal, or // more exist on the server beyond what we've fetched. const shownCount = shown.length; if (shownCount < _libraryTotal) { const btn = document.createElement('button'); btn.className = 'doclib-load-more doclib-inline-load-more'; btn.id = 'doclib-docs-load-more'; btn.textContent = `Load more (${shownCount} of ${_libraryTotal})`; btn.addEventListener('click', async () => { _docsVisibleLimit += 20; // Need more than we've fetched? pull the next server page first. if (_docsVisibleLimit > _libraryDocs.length && _libraryDocs.length < _libraryTotal) { _libraryOffset = _libraryDocs.length; await libraryFetch(true); // appends + re-renders } else { libraryRenderGrid(); } }); grid.parentElement.appendChild(btn); } } // Infinite scroll for the library (mobile + desktop), covering EVERY tab — // Documents, Chats, Research, Archive all render a `.doclib-inline-load-more` // button (regenerated fresh each render). A capture-phase scroll listener // catches whichever element actually scrolls and, when the visible button // nears the viewport bottom, clicks it — reusing each tab's own load logic. // We mark a button once clicked so the SAME instance can't double-fire (the // next render makes a fresh, unmarked one), which is safe for both the sync // reveal tabs (Chats/Research) and the async fetch tabs (Documents/Archive). if (!_libScrollBound) { _libScrollBound = true; let _tick = false; const _maybeAutoLoad = () => { _tick = false; if (!_libraryOpen) return; for (const btn of document.querySelectorAll('.doclib-inline-load-more')) { if (btn.dataset.autoLoaded) continue; if (!btn.offsetParent) continue; // inactive tab (hidden) if (btn.getBoundingClientRect().top > window.innerHeight + 600) continue; btn.dataset.autoLoaded = '1'; btn.click(); break; // one load per scroll tick } }; document.addEventListener('scroll', () => { if (_tick) return; _tick = true; requestAnimationFrame(_maybeAutoLoad); }, true); } function libraryCreateCard(doc) { const card = document.createElement('div'); card.className = 'doclib-card memory-item'; card.dataset.docId = doc.id; if (_librarySelectMode && _librarySelectedIds.has(doc.id)) { card.classList.add('selected'); } // Checkbox for select mode if (_librarySelectMode) { const cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'memory-select-cb'; cb.checked = _librarySelectedIds.has(doc.id); cb.addEventListener('click', (e) => e.stopPropagation()); cb.addEventListener('change', () => { libraryToggleSelectItem(doc.id); card.classList.toggle('selected', _librarySelectedIds.has(doc.id)); const selectAllEl = document.getElementById('doclib-select-all'); if (selectAllEl) selectAllEl.checked = _libraryDocs.every(d => _librarySelectedIds.has(d.id)); }); card.appendChild(cb); } // Content wrapper const content = document.createElement('div'); content.style.cssText = 'flex:1;min-width:0;padding-top:4px;'; // Title row with version badge const titleRow = document.createElement('div'); titleRow.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%;'; const titleEl = document.createElement('span'); titleEl.className = 'memory-item-title'; titleEl.style.cssText = 'flex:0 1 auto;min-width:0;'; // Language-specific icon next to the title (matches the document's type: // markdown/csv/python/html/etc.). Falls back to the generic document icon // when the language has no dedicated glyph. const _GEN_DOC_ICON = ''; const _langSvg = doc.language && doc.language !== 'text' ? langIcon(doc.language, 12, { style: 'vertical-align:-2px;margin-right:4px;opacity:0.55;flex-shrink:0;color:currentColor;' }) : ''; titleEl.innerHTML = (_langSvg || _GEN_DOC_ICON) + _hlSearch(doc.title || 'Untitled'); titleRow.appendChild(titleEl); const verBadge = document.createElement('span'); 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;'; verBadge.textContent = 'v' + (doc.version_count || 1); titleRow.appendChild(verBadge); // Chevron pushed to the right end of the title row — collapsed // shows nothing, expanded reveals a downward chevron so the user // sees the card is open and can tap to close it. const chevron = document.createElement('span'); chevron.className = 'doclib-card-chevron'; chevron.style.marginLeft = 'auto'; chevron.innerHTML = ''; titleRow.appendChild(chevron); content.appendChild(titleRow); // Meta line: session → [lang-icon language] → time const meta = document.createElement('div'); meta.className = 'memory-item-meta'; meta.style.cssText = 'font-size:10px;opacity:0.55;margin-top:2px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;'; const _esc = (s) => uiModule.esc(String(s || '')); const pieces = []; if (doc.session_name) pieces.push(`${_esc(doc.session_name)}`); if (doc.language && doc.language !== 'text') { const ic = langIcon(doc.language, 11, { style: 'vertical-align:-2px;flex-shrink:0;opacity:0.65;color:currentColor;' }); pieces.push(`${ic}${_esc(doc.language)}`); } pieces.push(`${_esc(libraryRelativeTime(doc.updated_at))}`); meta.innerHTML = pieces.join('\u00b7'); // Strip the per-language icon from the meta line \u2014 it now sits next to the // title above, so duplicating it here was redundant. content.appendChild(meta); card.appendChild(content); // Header element (kept for expand/preview compatibility) const header = document.createElement('div'); header.className = 'doclib-card-header'; header.style.display = 'none'; // Action buttons — "..." menu const actionsWrap = document.createElement('div'); actionsWrap.className = 'memory-item-actions'; const menuWrap = document.createElement('span'); menuWrap.className = 'doclib-card-menu-wrap'; menuWrap.style.position = 'relative'; const menuBtn = document.createElement('button'); menuBtn.className = 'memory-item-btn'; menuBtn.title = 'Actions'; menuBtn.innerHTML = ''; menuBtn.addEventListener('click', (e) => { e.stopPropagation(); // Mobile: the custom 5-item dropdown is too crowded — route through the // shared _showLibDropdown with a small set (Open, Clone) plus Select + // Cancel. Heavier actions (Archive, Delete, Export) live in bulk mode. if (window.innerWidth <= 768) { const items = []; if (doc.session_id) items.push({ label: 'Open', action: () => libraryOpenInSession(doc) }); items.push({ label: 'Clone', action: () => libraryImportDocument(doc) }); _showLibDropdown(menuBtn, items, { onSelect: () => { libraryEnterSelectMode(); _librarySelectedIds.add(doc.id); libraryUpdateBulkCount(); libraryRenderGrid(); } }); return; } const dropdown = menuWrap.querySelector('.doclib-card-dropdown') || document.body.querySelector('.doclib-card-dropdown[data-owner="' + CSS.escape(doc.id) + '"]'); if (dropdown) { const isOpen = dropdown.style.display !== 'none' && dropdown.parentElement === document.body; if (isOpen) { hideCardDropdown(); } else { // Position fixed on body to escape overflow clipping const rect = menuBtn.getBoundingClientRect(); document.body.appendChild(dropdown); dropdown.dataset.owner = doc.id; 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;'; dropdown.style.top = (rect.bottom + 4) + 'px'; dropdown.style.left = 'auto'; dropdown.style.right = (window.innerWidth - rect.right) + 'px'; // Clamp to viewport requestAnimationFrame(() => { const mr = dropdown.getBoundingClientRect(); if (mr.bottom > window.innerHeight - 8) dropdown.style.top = (rect.top - mr.height - 4) + 'px'; if (mr.left < 8) { dropdown.style.left = '8px'; dropdown.style.right = 'auto'; } }); // Close on outside click or Escape (the latter via the registry). _cardDocClick = (ev) => { if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) hideCardDropdown(); }; setTimeout(() => document.addEventListener('click', _cardDocClick, true), 0); _cardUnreg = registerMenuDismiss(hideCardDropdown); } } }); menuWrap.appendChild(menuBtn); // Dropdown menu const dropdown = document.createElement('div'); dropdown.className = 'doclib-card-dropdown'; 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;'; // Single close path for the card action dropdown, shared by the toggle // button, the outside-click listener, every menu item, and the Escape // arbiter (via registerMenuDismiss). Hides the menu, returns it to its // wrapper, drops the outside-click listener, and unregisters from the // Escape stack. Idempotent — safe to call from whichever path fires first. let _cardUnreg = () => {}; let _cardDocClick = null; function hideCardDropdown() { _cardUnreg(); _cardUnreg = () => {}; if (_cardDocClick) { document.removeEventListener('click', _cardDocClick, true); _cardDocClick = null; } dropdown.style.display = 'none'; if (dropdown.parentElement === document.body) menuWrap.appendChild(dropdown); } dropdown._dismiss = hideCardDropdown; // bulk removers tear down through this const _di = (svg) => `${svg}`; const _openIco = ''; // Open const openItem = document.createElement('button'); openItem.className = 'dropdown-item-compact'; openItem.style.cssText = 'background:none;border:none;width:100%;'; openItem.innerHTML = _di(_openIco) + 'Open'; if (doc.session_id) { openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenInSession(doc); }); } else { // Orphaned doc (closed / session detached) is still openable in the editor // by id — libraryOpenDocument handles the no-session case (#1602). openItem.title = 'Open in the editor'; openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenDocument(doc); }); } dropdown.appendChild(openItem); // Clone const _cloneIco = ''; const cloneItem = document.createElement('button'); cloneItem.className = 'dropdown-item-compact'; cloneItem.style.cssText = 'background:none;border:none;width:100%;'; cloneItem.innerHTML = _di(_cloneIco) + 'Clone'; cloneItem.title = 'Clone to active session'; cloneItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryImportDocument(doc); }); dropdown.appendChild(cloneItem); // Export const _exportIco = ''; const exportItem = document.createElement('button'); exportItem.className = 'dropdown-item-compact'; exportItem.style.cssText = 'background:none;border:none;width:100%;'; exportItem.innerHTML = _di(_exportIco) + 'Export'; exportItem.addEventListener('click', async (e) => { e.stopPropagation(); hideCardDropdown(); try { const res = await fetch(`${API_BASE}/api/document/${doc.id}`); if (!res.ok) throw new Error('Failed'); const full = await res.json(); 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' }; const ext = extMap[full.language] || '.txt'; const blob = new Blob([full.current_content || ''], { type: 'text/plain' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (full.title || 'document') + ext; a.click(); URL.revokeObjectURL(a.href); } catch { if (uiModule) uiModule.showError('Failed to export document'); } }); dropdown.appendChild(exportItem); // Archive / Restore — soft-archive a doc out of the main list, or bring it back. const _archiveIco = ''; const archiveItem = document.createElement('button'); archiveItem.className = 'dropdown-item-compact'; archiveItem.style.cssText = 'background:none;border:none;width:100%;'; archiveItem.innerHTML = _di(_archiveIco) + `${_libraryArchivedView ? 'Restore' : 'Archive'}`; archiveItem.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)'; archiveItem.addEventListener('click', async (e) => { e.stopPropagation(); hideCardDropdown(); const toArchived = !_libraryArchivedView; try { const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' }); if (!res.ok) throw new Error('failed'); // Drop it from the current view (it no longer belongs here) and refresh. libraryRemoveDocumentFromState(doc.id); libraryRenderGrid(); if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored'); } catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); } }); dropdown.appendChild(archiveItem); // Delete const _deleteIco = ''; const deleteItem = document.createElement('button'); deleteItem.className = 'dropdown-item-compact dropdown-item-danger'; deleteItem.style.cssText = 'background:none;border:none;width:100%;'; deleteItem.innerHTML = _di(_deleteIco) + 'Delete'; deleteItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryDeleteSingle(doc.id, card); }); dropdown.appendChild(deleteItem); menuWrap.appendChild(dropdown); actionsWrap.appendChild(menuWrap); card.appendChild(actionsWrap); // Hidden header for expand/preview compatibility card.appendChild(header); // Inject library card hover styles once if (!document.getElementById('doclib-card-styles')) { const s = document.createElement('style'); s.id = 'doclib-card-styles'; 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}`; document.head.appendChild(s); } // Preview — hidden by default, shown on expand const preview = document.createElement('div'); preview.className = 'doclib-card-preview'; const pre = document.createElement('pre'); const code = document.createElement('code'); try { if (doc.language && doc.language !== 'text' && window.hljs && !_librarySearch) { code.innerHTML = window.hljs.highlight(doc.preview || '', { language: doc.language }).value; } else if (_librarySearch) { // While searching, highlight matched terms in the preview (plain // text) rather than syntax-highlighting — the match is what matters. code.innerHTML = _hlSearch(doc.preview || ''); } else { code.textContent = doc.preview || ''; } } catch { code.textContent = doc.preview || ''; } pre.appendChild(code); preview.appendChild(pre); // Expanded-only action bar — inside preview const expandedActions = document.createElement('div'); expandedActions.className = 'doclib-card-expanded-actions'; const openBtn = document.createElement('button'); openBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; openBtn.innerHTML = 'Open'; if (doc.session_id) { openBtn.title = 'Open in original session'; openBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryOpenInSession(doc); }); } else { // Orphaned doc (closed / session detached) is still openable in the editor // by id — libraryOpenDocument handles the no-session case (#1602). openBtn.title = 'Open in the editor'; openBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryOpenDocument(doc); }); } const cloneBtn = document.createElement('button'); cloneBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; cloneBtn.innerHTML = 'Clone'; cloneBtn.title = 'Clone — copy to active session'; cloneBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryImportDocument(doc); }); const deleteBtn = document.createElement('button'); deleteBtn.className = 'doclib-card-text-btn doclib-card-action-btn doclib-card-text-btn-danger'; deleteBtn.innerHTML = 'Delete'; deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryDeleteSingle(doc.id, card); }); // Archive sits next to Delete on the LEFT — same lineup as the chat // and research footers. Label flips to Restore inside the Archive view. const archiveBtn = document.createElement('button'); archiveBtn.className = 'doclib-card-text-btn doclib-card-action-btn'; archiveBtn.innerHTML = '' + (_libraryArchivedView ? 'Restore' : 'Archive'); archiveBtn.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)'; archiveBtn.addEventListener('click', async (e) => { e.stopPropagation(); const toArchived = !_libraryArchivedView; try { const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' }); if (!res.ok) throw new Error('failed'); libraryRemoveDocumentFromState(doc.id); libraryRenderGrid(); if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored'); } catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); } }); const leftGroup = document.createElement('div'); leftGroup.className = 'doclib-action-group'; const btnRow = document.createElement('div'); btnRow.className = 'doclib-action-btn-row'; // Export lives in the ⋮ menu — keep the footer uncrowded with Clone + Open. btnRow.appendChild(cloneBtn); btnRow.appendChild(openBtn); leftGroup.appendChild(btnRow); // Delete furthest LEFT, then Archive; Open/Clone group on the RIGHT. // Nudge the Delete/Archive pair 8px left for alignment. deleteBtn.style.cssText += ';position:relative;left:-8px;'; archiveBtn.style.cssText += ';position:relative;left:-8px;'; expandedActions.appendChild(deleteBtn); expandedActions.appendChild(archiveBtn); expandedActions.appendChild(leftGroup); preview.appendChild(expandedActions); card.appendChild(preview); card.addEventListener('click', () => { if (card._suppressNextClick) { card._suppressNextClick = false; return; } if (_librarySelectMode) { const cb = card.querySelector('.memory-select-cb'); if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); } } else { libraryExpandCard(card, doc); } }); _attachLongPressMenu(card, '.memory-item-btn'); return card; } async function libraryExpandCard(card, doc) { const grid = card.closest('.doclib-grid'); const instant = card?.dataset?.spaceToggle === '1'; // Already expanded — collapse if (card.classList.contains('doclib-card-expanded')) { _collapseExpandedCard(card); return; } // Collapse any other expanded card if (grid) { grid.querySelectorAll('.doclib-card-expanded').forEach(c => _collapseExpandedCard(c)); } // Fade siblings out before the CSS display:none kicks in const siblings = grid ? [...grid.querySelectorAll('.doclib-card')].filter(c => c !== card) : []; // Force explicit starting opacity so the first transition works siblings.forEach(s => { s.style.opacity = '1'; }); // Force reflow so the browser registers the starting value if (!instant) { if (siblings.length) siblings[0].offsetHeight; siblings.forEach(s => { s.style.transition = 'opacity 0.12s ease'; s.style.opacity = '0'; }); } // Capture the full grid + toolbar height so the modal stays the same // size on desktop. On mobile the modal is full-height and we want the // grid to claim all available space — skip the lock there. const isMobile = window.innerWidth <= 768; const toolbar = grid ? grid.closest('.admin-card')?.querySelector('.memory-toolbar') : null; const toolbarH = toolbar ? toolbar.offsetHeight : 0; if (grid && !isMobile) { grid.style.minHeight = (grid.offsetHeight + toolbarH) + 'px'; grid.style.maxHeight = (grid.offsetHeight + toolbarH) + 'px'; } // Wait for fade-out, then expand if (!instant) await new Promise(r => setTimeout(r, 120)); card.classList.add('doclib-card-expanded'); if (grid) grid.scrollTop = 0; // Clean up sibling inline styles (CSS display:none takes over now) siblings.forEach(s => { s.style.transition = ''; s.style.opacity = ''; }); // Load full content into preview area const preview = card.querySelector('.doclib-card-preview'); if (!preview) return; const actionsBar = preview.querySelector('.doclib-card-expanded-actions'); const existingPre = preview.querySelector('pre'); try { const res = await fetch(`${API_BASE}/api/document/${doc.id}`); if (!res.ok) throw new Error('Failed'); const full = await res.json(); const content = full.current_content || ''; const lang = full.language || doc.language || 'text'; // PDF-backed docs have a marker comment in their markdown — show the // rendered PDF in an iframe instead of dumping markdown source. const isPdfDoc = /