2026-05-31 23:58:26 +09:00
// 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' ;
// ── 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 <mark>. 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' ) ,
'<mark class="doclib-search-hl">$1</mark>' ) ;
} 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' ) ;
2026-06-01 14:50:06 +09:00
const instant = card ? . dataset ? . spaceToggle === '1' ;
2026-05-31 23:58:26 +09:00
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
2026-06-01 14:50:06 +09:00
if ( grid && ! instant ) {
2026-05-31 23:58:26 +09:00
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 ( /<think>[\s\S]*?<\/think>/g , '' )
. replace ( /<think>[\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 : '<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>' ,
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>' ,
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>' ,
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>' ,
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>' ,
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>' ,
} ;
function _showLibDropdown ( anchor , items , opts ) {
opts = opts || { } ;
document . querySelectorAll ( '._lib-dd' ) . forEach ( d => d . remove ( ) ) ;
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 ? '<span class="dropdown-icon">' + iconSvg + '</span>' : '' ) + '<span>' + item . label + '</span>' ;
row . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; dd . remove ( ) ; item . action ( ) ; } ) ;
dd . appendChild ( row ) ;
}
if ( typeof opts . onSelect === 'function' ) {
const sel = document . createElement ( 'div' ) ;
sel . className = 'dropdown-item-compact' ;
sel . innerHTML =
'<span class="dropdown-icon"><span style="font-size:16px;line-height:1;position:relative;top:-2px;">●</span></span>'
+ '<span>Select</span>' ;
sel . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; dd . remove ( ) ; opts . onSelect ( ) ; } ) ;
dd . appendChild ( sel ) ;
}
const cancel = document . createElement ( 'div' ) ;
cancel . className = 'dropdown-item-compact dropdown-cancel-mobile' ;
cancel . innerHTML =
'<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>'
+ '<span>Cancel</span>' ;
cancel . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; dd . remove ( ) ; 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' ; }
} ) ;
const close = ( e ) => { if ( ! dd . contains ( e . target ) ) { dd . remove ( ) ; document . removeEventListener ( 'click' , close ) ; } } ;
setTimeout ( ( ) => document . addEventListener ( 'click' , close ) , 0 ) ;
// 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' ;
setTimeout ( ( ) => dd . remove ( ) , 160 ) ;
document . removeEventListener ( 'click' , close ) ;
} 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 libraryRenderGrid ( ) {
const grid = document . getElementById ( 'doclib-grid' ) ;
if ( ! grid ) return ;
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 = '<div class="doclib-empty">No documents match your search.</div>' ;
} else {
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>' ;
grid . innerHTML =
'<div class="doclib-empty" style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;">' +
'<span>No documents yet</span>' +
'<span style="opacity:0.7;font-size:11px;">' +
'<a href="#" data-doclib-import style="color:var(--accent,var(--red));text-decoration:underline;">Import' + _impIco + '</a>' +
' · or create one in a session' +
'</span>' +
'</div>' ;
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 = '<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>' ;
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 = '<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>' ;
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 ( ` <span> ${ _esc ( doc . session _name ) } </span> ` ) ;
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 ( ` <span style="display:inline-flex;align-items:center;gap:3px;"> ${ ic } ${ _esc ( doc . language ) } </span> ` ) ;
}
pieces . push ( ` <span> ${ _esc ( libraryRelativeTime ( doc . updated _at ) ) } </span> ` ) ;
meta . innerHTML = pieces . join ( '<span style="opacity:0.5;">\u00b7</span>' ) ;
// 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 = '<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>' ;
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 ) {
dropdown . style . display = 'none' ;
menuWrap . appendChild ( dropdown ) ;
} 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
const close = ( ev ) => {
if ( ! dropdown . contains ( ev . target ) && ! menuWrap . contains ( ev . target ) ) {
dropdown . style . display = 'none' ;
menuWrap . appendChild ( dropdown ) ;
document . removeEventListener ( 'click' , close , true ) ;
}
} ;
setTimeout ( ( ) => document . addEventListener ( 'click' , close , true ) , 0 ) ;
}
}
} ) ;
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;' ;
const _di = ( svg ) => ` <span class="dropdown-icon"> ${ svg } </span> ` ;
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>' ;
// Open
const openItem = document . createElement ( 'button' ) ;
openItem . className = 'dropdown-item-compact' ;
openItem . style . cssText = 'background:none;border:none;width:100%;' ;
openItem . innerHTML = _di ( _openIco ) + '<span>Open</span>' ;
if ( doc . session _id ) {
openItem . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; dropdown . style . display = 'none' ; libraryOpenInSession ( doc ) ; } ) ;
} else {
openItem . disabled = true ;
openItem . style . opacity = '0.35' ;
openItem . title = 'Not linked to a session' ;
}
dropdown . appendChild ( openItem ) ;
// Clone
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>' ;
const cloneItem = document . createElement ( 'button' ) ;
cloneItem . className = 'dropdown-item-compact' ;
cloneItem . style . cssText = 'background:none;border:none;width:100%;' ;
cloneItem . innerHTML = _di ( _cloneIco ) + '<span>Clone</span>' ;
cloneItem . title = 'Clone to active session' ;
cloneItem . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; dropdown . style . display = 'none' ; libraryImportDocument ( doc ) ; } ) ;
dropdown . appendChild ( cloneItem ) ;
// Export
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>' ;
const exportItem = document . createElement ( 'button' ) ;
exportItem . className = 'dropdown-item-compact' ;
exportItem . style . cssText = 'background:none;border:none;width:100%;' ;
exportItem . innerHTML = _di ( _exportIco ) + '<span>Export</span>' ;
exportItem . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
dropdown . style . display = 'none' ;
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 = '<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>' ;
const archiveItem = document . createElement ( 'button' ) ;
archiveItem . className = 'dropdown-item-compact' ;
archiveItem . style . cssText = 'background:none;border:none;width:100%;' ;
archiveItem . innerHTML = _di ( _archiveIco ) + ` <span> ${ _libraryArchivedView ? 'Restore' : 'Archive' } </span> ` ;
archiveItem . title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)' ;
archiveItem . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
dropdown . style . display = 'none' ;
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.
_libraryDocs = _libraryDocs . filter ( d => d . id !== doc . id ) ;
_libraryTotal = Math . max ( 0 , _libraryTotal - 1 ) ;
libraryRenderGrid ( ) ;
if ( uiModule ) uiModule . showToast ( toArchived ? 'Archived' : 'Restored' ) ;
} catch { if ( uiModule ) uiModule . showError ( 'Failed to ' + ( toArchived ? 'archive' : 'restore' ) ) ; }
} ) ;
dropdown . appendChild ( archiveItem ) ;
// Delete
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>' ;
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 ) + '<span>Delete</span>' ;
deleteItem . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; dropdown . style . display = 'none' ; 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 = '<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' ;
if ( doc . session _id ) {
openBtn . title = 'Open in original session' ;
openBtn . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; libraryOpenInSession ( doc ) ; } ) ;
} else {
openBtn . disabled = true ;
openBtn . style . opacity = '0.35' ;
openBtn . style . cursor = 'not-allowed' ;
openBtn . title = 'This document is not linked to a session' ;
}
const cloneBtn = document . createElement ( 'button' ) ;
cloneBtn . className = 'doclib-card-text-btn doclib-card-action-btn' ;
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' ;
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 = '<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' ;
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 = '<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' ) ;
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' ) ;
_libraryDocs = _libraryDocs . filter ( d => d . id !== doc . id ) ;
_libraryTotal = Math . max ( 0 , _libraryTotal - 1 ) ;
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' ) ;
2026-06-01 14:50:06 +09:00
const instant = card ? . dataset ? . spaceToggle === '1' ;
2026-05-31 23:58:26 +09:00
// 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
2026-06-01 14:50:06 +09:00
if ( ! instant ) {
if ( siblings . length ) siblings [ 0 ] . offsetHeight ;
siblings . forEach ( s => { s . style . transition = 'opacity 0.12s ease' ; s . style . opacity = '0' ; } ) ;
}
2026-05-31 23:58:26 +09:00
// 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
2026-06-01 14:50:06 +09:00
if ( ! instant ) await new Promise ( r => setTimeout ( r , 120 ) ) ;
2026-05-31 23:58:26 +09:00
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 = /<!--\s*pdf_(?:form_)?source\s+upload_id="[^"]+"/ . test ( content ) ;
const existingFrame = preview . querySelector ( '.doclib-card-pdf-frame' ) ;
if ( isPdfDoc ) {
const frame = document . createElement ( 'iframe' ) ;
frame . className = 'doclib-card-pdf-frame' ;
frame . src = ` ${ API _BASE } /api/document/ ${ doc . id } /render-pdf?t= ${ Date . now ( ) } ` ;
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;' ;
if ( existingPre ) existingPre . remove ( ) ;
if ( existingFrame ) existingFrame . remove ( ) ;
preview . insertBefore ( frame , preview . firstChild ) ;
if ( actionsBar && ! preview . contains ( actionsBar ) ) preview . appendChild ( actionsBar ) ;
requestAnimationFrame ( ( ) => { frame . style . opacity = '1' ; } ) ;
return ;
}
const pre = document . createElement ( 'pre' ) ;
const code = document . createElement ( 'code' ) ;
// Syntax highlighting is synchronous and O(n) — running it over a whole
// large document froze the main thread on click (the "lag"). Only
// highlight up to a cap; bigger docs render as plain text (still fully
// shown) so the preview opens instantly. Markdown gains little from
// highlighting anyway, so skip it there.
const HL _CAP = 20000 ;
try {
if ( lang && lang !== 'text' && lang !== 'markdown' && window . hljs && content . length <= HL _CAP ) {
code . innerHTML = window . hljs . highlight ( content , { language : lang } ) . value ;
} else {
code . textContent = content ;
}
} catch {
code . textContent = content ;
}
pre . appendChild ( code ) ;
// Swap content — fade in the full version
if ( existingPre ) existingPre . remove ( ) ;
if ( existingFrame ) existingFrame . remove ( ) ;
pre . style . opacity = '0' ;
preview . insertBefore ( pre , preview . firstChild ) ;
if ( actionsBar && ! preview . contains ( actionsBar ) ) preview . appendChild ( actionsBar ) ;
requestAnimationFrame ( ( ) => {
pre . style . transition = 'opacity 0.15s ease' ;
pre . style . opacity = '1' ;
} ) ;
} catch ( e ) {
// On error, keep existing preview if available
if ( ! existingPre ) {
preview . innerHTML = '<div style="padding:8px;color:var(--color-error);font-size:10px;">Failed to load</div>' ;
}
if ( actionsBar && ! preview . contains ( actionsBar ) ) preview . appendChild ( actionsBar ) ;
}
}
function libraryRenderLoadMore ( ) {
// Documents now reveal in 20-at-a-time chunks via the inline "Load more"
// rendered inside libraryRenderGrid (matching the Chats tab). The legacy
// server-pagination button + auto-fill are retired to avoid a double
// control and surprise auto-loading.
const legacy = document . getElementById ( 'doclib-load-more' ) ;
if ( legacy ) legacy . style . display = 'none' ;
}
async function libraryOpenDocument ( doc ) {
closeLibrary ( ) ;
// Orphaned doc (session deleted) — just open in editor without switching session
if ( ! doc . session _id ) {
_loadDocument ( doc . id ) ;
return ;
}
const currentSessionId = sessionModule && sessionModule . getCurrentSessionId ( ) ;
if ( doc . session _id !== currentSessionId ) {
await sessionModule . selectSession ( doc . session _id ) ;
}
_loadDocument ( doc . id ) ;
}
/** Open a document in its linked session */
async function libraryOpenInSession ( doc ) {
if ( ! doc . session _id ) return ;
closeLibrary ( ) ;
// Step 1: switch session if needed and wait for it to load
const currentSessionId = sessionModule && sessionModule . getCurrentSessionId ( ) ;
if ( doc . session _id !== currentSessionId ) {
await sessionModule . selectSession ( doc . session _id ) ;
// Give the session UI a moment to settle
await new Promise ( r => setTimeout ( r , 150 ) ) ;
}
// Step 2: ensure doc is in tabs
const docs = _getDocs ( ) ;
if ( ! docs . has ( doc . id ) ) {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ doc . id } ` ) ;
if ( res . ok ) {
const full = await res . json ( ) ;
_addDocToTabs ( full , doc . session _id ) ;
}
}
// Step 3: open panel (slide-in is handled by openPanel)
if ( ! _isOpenFn ( ) ) _openPanel ( ) ;
_switchToDoc ( doc . id ) ;
_syncDocIndicator ( ) ;
}
/** Copy a document from the library into the current session */
async function libraryImportDocument ( doc ) {
let sessionId = sessionModule && sessionModule . getCurrentSessionId ( ) ;
if ( ! sessionId ) {
// Create a new session if none exists
if ( sessionModule && sessionModule . hasPendingChat && sessionModule . hasPendingChat ( ) ) {
const ok = await sessionModule . materializePendingSession ( ) ;
if ( ok ) sessionId = sessionModule . getCurrentSessionId ( ) ;
}
if ( ! sessionId ) {
// No pending chat either — trigger new session, preserving the current model
const curModel = sessionModule . getCurrentModel ? sessionModule . getCurrentModel ( ) : null ;
const sessions = sessionModule ? sessionModule . getSessions ( ) : [ ] ;
// Prefer the session matching the current model, otherwise fall back to first with a model
const withModel = sessions . filter ( s => s . endpoint _url && s . model ) ;
const match = ( curModel && withModel . find ( s => s . model === curModel ) ) || withModel [ 0 ] ;
if ( match ) {
sessionModule . createDirectChat ( match . endpoint _url , match . model , match . endpoint _id ) ;
const ok = await sessionModule . materializePendingSession ( ) ;
if ( ok ) sessionId = sessionModule . getCurrentSessionId ( ) ;
}
}
if ( ! sessionId ) {
if ( uiModule ) uiModule . showError ( 'Could not create a session' ) ;
return ;
}
}
try {
// Fetch full content of the source document
const srcRes = await fetch ( ` ${ API _BASE } /api/document/ ${ doc . id } ` ) ;
if ( ! srcRes . ok ) throw new Error ( 'Failed to fetch document' ) ;
const src = await srcRes . json ( ) ;
// Deduplicate title — append (2), (3), etc. if name already exists in session
let baseTitle = src . title || doc . title || 'Untitled' ;
const existingTitles = new Set ( ) ;
const docs = _getDocs ( ) ;
for ( const [ , d ] of docs ) {
if ( d . sessionId === sessionId && d . title ) existingTitles . add ( d . title ) ;
}
if ( existingTitles . has ( baseTitle ) ) {
// Strip existing (N) suffix to get the root name
const root = baseTitle . replace ( /\s*\(\d+\)$/ , '' ) ;
let n = 2 ;
while ( existingTitles . has ( root + ' (' + n + ')' ) ) n ++ ;
baseTitle = root + ' (' + n + ')' ;
}
// Create a new document copy in the current session
const res = await fetch ( ` ${ API _BASE } /api/document ` , {
method : 'POST' ,
credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
session _id : sessionId ,
// Preserve the source's type; default to markdown when unknown
// (the backend also sniffs, but this keeps the tab label correct).
language : src . language || doc . language || 'markdown' ,
content : src . current _content || '' ,
} ) ,
} ) ;
if ( ! res . ok ) throw new Error ( 'Failed to create document' ) ;
const created = await res . json ( ) ;
closeLibrary ( ) ;
_addDocToTabs ( created , sessionId ) ;
if ( ! _isOpenFn ( ) ) _openPanel ( ) ;
_switchToDoc ( created . id ) ;
_syncDocIndicator ( ) ;
if ( uiModule ) uiModule . showToast ( 'Document cloned to session' ) ;
} catch ( e ) {
console . error ( 'Failed to import document:' , e ) ;
if ( uiModule ) uiModule . showError ( 'Failed to import document' ) ;
}
}
// ---- Library bulk operations ----
function libraryEnterSelectMode ( ) {
_librarySelectMode = true ;
_librarySelectedIds . clear ( ) ;
const bulkBar = document . getElementById ( 'doclib-bulk-bar' ) ;
const selectBtn = document . getElementById ( 'doclib-select-btn' ) ;
if ( bulkBar ) bulkBar . classList . remove ( 'hidden' ) ;
if ( selectBtn ) { selectBtn . classList . add ( 'active' ) ; selectBtn . textContent = 'Cancel' ; }
libraryUpdateBulkCount ( ) ;
libraryRenderGrid ( ) ;
}
function libraryExitSelectMode ( ) {
_librarySelectMode = false ;
_librarySelectedIds . clear ( ) ;
const bulkBar = document . getElementById ( 'doclib-bulk-bar' ) ;
const selectBtn = document . getElementById ( 'doclib-select-btn' ) ;
const selectAll = document . getElementById ( 'doclib-select-all' ) ;
if ( bulkBar ) bulkBar . classList . add ( 'hidden' ) ;
if ( selectBtn ) { selectBtn . classList . remove ( 'active' ) ; selectBtn . textContent = 'Select' ; }
if ( selectAll ) selectAll . checked = false ;
libraryRenderGrid ( ) ;
}
function libraryToggleSelectItem ( id ) {
if ( _librarySelectedIds . has ( id ) ) {
_librarySelectedIds . delete ( id ) ;
} else {
_librarySelectedIds . add ( id ) ;
}
libraryUpdateBulkCount ( ) ;
}
function libraryToggleSelectAll ( ) {
const selectAllEl = document . getElementById ( 'doclib-select-all' ) ;
if ( ! selectAllEl ) return ;
if ( selectAllEl . checked ) {
_libraryDocs . forEach ( d => _librarySelectedIds . add ( d . id ) ) ;
} else {
_librarySelectedIds . clear ( ) ;
}
libraryUpdateBulkCount ( ) ;
libraryRenderGrid ( ) ;
}
function libraryUpdateBulkCount ( ) {
const countEl = document . getElementById ( 'doclib-selected-count' ) ;
const actionsBtn = document . getElementById ( 'doclib-bulk-actions' ) ;
if ( countEl ) countEl . textContent = ` ${ _librarySelectedIds . size } Selected ` ;
if ( actionsBtn ) actionsBtn . style . color = _librarySelectedIds . size > 0 ? 'var(--fg)' : '' ;
// Legacy per-action buttons no longer rendered — guard so the rest of the
// function (if anything still references them) doesn't crash.
const deleteBtn = document . getElementById ( 'doclib-bulk-delete' ) ;
const exportBtn = document . getElementById ( 'doclib-bulk-export' ) ;
const archiveBtn = document . getElementById ( 'doclib-bulk-archive' ) ;
const cloneBtn = document . getElementById ( 'doclib-bulk-clone' ) ;
if ( deleteBtn ) deleteBtn . disabled = _librarySelectedIds . size === 0 ;
if ( exportBtn ) exportBtn . disabled = _librarySelectedIds . size === 0 ;
if ( cloneBtn ) cloneBtn . disabled = _librarySelectedIds . size === 0 ;
if ( archiveBtn ) {
archiveBtn . disabled = _librarySelectedIds . size === 0 ;
archiveBtn . textContent = _libraryArchivedView ? 'Restore' : 'Archive' ;
}
}
async function libraryDeleteSingle ( docId , card ) {
if ( uiModule && uiModule . styledConfirm ) {
const ok = await uiModule . styledConfirm ( 'Delete this document?' , { confirmText : 'Delete' , danger : true } ) ;
if ( ! ok ) return ;
} else if ( ! confirm ( 'Delete this document?' ) ) {
return ;
}
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ docId } ` , { method : 'DELETE' , credentials : 'same-origin' } ) ;
if ( ! res . ok ) {
let detail = ` HTTP ${ res . status } ` ;
try { const j = await res . json ( ) ; if ( j ? . detail ) detail = j . detail ; } catch { }
throw new Error ( detail ) ;
}
if ( card ) {
card . classList . add ( 'doclib-card-deleting' ) ;
card . addEventListener ( 'transitionend' , ( ) => card . remove ( ) , { once : true } ) ;
setTimeout ( ( ) => { if ( card . parentElement ) card . remove ( ) ; } , 400 ) ;
}
_libraryDocs = _libraryDocs . filter ( d => d . id !== docId ) ;
_libraryTotal = Math . max ( 0 , _libraryTotal - 1 ) ;
libraryRenderStats ( ) ;
if ( uiModule ) uiModule . showToast ( 'Document deleted' ) ;
} catch ( e ) {
if ( uiModule ) uiModule . showError ( ` Failed to delete document: ${ e . message || e } ` ) ;
}
}
async function libraryBulkDelete ( ) {
if ( _librarySelectedIds . size === 0 ) return ;
const count = _librarySelectedIds . size ;
if ( uiModule && uiModule . styledConfirm ) {
const ok = await uiModule . styledConfirm (
` Delete ${ count } document ${ count !== 1 ? 's' : '' } ? ` ,
{ confirmText : 'Delete' , danger : true }
) ;
if ( ! ok ) return ;
} else if ( ! confirm ( ` Delete ${ count } document ${ count !== 1 ? 's' : '' } ? ` ) ) {
return ;
}
let deleted = 0 ;
let failed = 0 ;
const deletedIds = [ ] ;
for ( const id of _librarySelectedIds ) {
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ id } ` , { method : 'DELETE' , credentials : 'same-origin' } ) ;
if ( res . ok ) {
deleted ++ ;
deletedIds . push ( id ) ;
}
else { failed ++ ; console . warn ( 'Delete failed for' , id , 'status' , res . status ) ; }
} catch ( e ) {
failed ++ ;
console . error ( 'Failed to delete document:' , id , e ) ;
}
}
for ( const id of deletedIds ) {
const card = document . querySelector ( ` .doclib-card[data-doc-id=" ${ CSS . escape ( String ( id ) ) } "] ` ) ;
if ( card ) card . classList . add ( 'doclib-card-deleting' ) ;
}
if ( deletedIds . length ) await new Promise ( r => setTimeout ( r , 320 ) ) ;
libraryExitSelectMode ( ) ;
await libraryFetch ( false ) ;
if ( uiModule ) {
const msg = failed > 0
? ` Deleted ${ deleted } · ${ failed } failed `
: ` Deleted ${ deleted } document ${ deleted !== 1 ? 's' : '' } ` ;
( failed > 0 ? uiModule . showError : uiModule . showToast ) ( msg ) ;
}
}
async function libraryBulkArchive ( ) {
if ( _librarySelectedIds . size === 0 ) return ;
const toArchived = ! _libraryArchivedView ;
const ids = [ ... _librarySelectedIds ] ;
let done = 0 , failed = 0 ;
for ( const id of ids ) {
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ id } /archive?archived= ${ toArchived } ` , { method : 'POST' , credentials : 'same-origin' } ) ;
if ( res . ok ) done ++ ; else failed ++ ;
} catch { failed ++ ; }
}
libraryExitSelectMode ( ) ;
await libraryFetch ( false ) ;
if ( uiModule ) {
const verb = toArchived ? 'Archived' : 'Restored' ;
const msg = failed > 0 ? ` ${ verb } ${ done } · ${ failed } failed ` : ` ${ verb } ${ done } document ${ done !== 1 ? 's' : '' } ` ;
( failed > 0 ? uiModule . showError : uiModule . showToast ) ( msg ) ;
}
}
// Bulk "Clone" — reuse libraryImportDocument for each selected doc.
// It handles session resolution + a possible new-session creation once
// (subsequent calls in the loop see the now-resolved session).
async function libraryBulkClone ( ) {
if ( _librarySelectedIds . size === 0 ) return ;
const ids = [ ... _librarySelectedIds ] ;
let done = 0 , failed = 0 ;
for ( const id of ids ) {
const doc = _libraryDocs . find ( d => d . id === id ) ;
if ( ! doc ) { failed ++ ; continue ; }
try {
const ok = await libraryImportDocument ( doc ) ;
if ( ok === false ) failed ++ ; else done ++ ;
} catch { failed ++ ; }
}
libraryExitSelectMode ( ) ;
if ( uiModule ) {
const msg = failed > 0
? ` Cloned ${ done } · ${ failed } failed `
: ` Cloned ${ done } document ${ done !== 1 ? 's' : '' } ` ;
( failed > 0 ? uiModule . showError : uiModule . showToast ) ( msg ) ;
}
}
async function libraryBulkExport ( ) {
if ( _librarySelectedIds . size === 0 ) return ;
// More than 5 → one server-built .zip (mirrors the gallery's bulk export;
// browsers also block a flood of individual downloads).
if ( _librarySelectedIds . size > 5 ) {
const ids = [ ... _librarySelectedIds ] ;
try {
if ( uiModule ) uiModule . showToast ( ` Zipping ${ ids . length } documents… ` ) ;
const res = await fetch ( ` ${ API _BASE } /api/documents/export-zip ` , {
method : 'POST' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { ids } ) ,
} ) ;
if ( ! res . ok ) throw new Error ( 'zip failed' ) ;
const blob = await res . blob ( ) ;
const url = URL . createObjectURL ( blob ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ;
a . download = 'documents.zip' ;
document . body . appendChild ( a ) ;
a . click ( ) ;
a . remove ( ) ;
setTimeout ( ( ) => URL . revokeObjectURL ( url ) , 2000 ) ;
if ( uiModule ) uiModule . showToast ( ` Exported ${ ids . length } documents (zip) ` ) ;
} catch ( e ) {
if ( uiModule ) uiModule . showError ( 'Failed to create zip' ) ;
}
return ;
}
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' , text : '.txt' ,
xml : '.xml' , toml : '.toml' , ini : '.ini' ,
} ;
const docs = await Promise . all ( [ ... _librarySelectedIds ] . map ( async id => {
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ id } ` ) ;
if ( ! res . ok ) return null ;
return await res . json ( ) ;
} catch ( e ) {
console . error ( 'Failed to export document:' , id , e ) ;
return null ;
}
} ) ) ;
for ( const doc of docs ) {
if ( ! doc ) continue ;
const ext = extMap [ doc . language ] || '.txt' ;
const filename = ( doc . title || 'document' ) + ( doc . title && doc . title . includes ( '.' ) ? '' : ext ) ;
const blob = new Blob ( [ doc . current _content || '' ] , { type : 'text/plain' } ) ;
const url = URL . createObjectURL ( blob ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ;
a . download = filename ;
a . click ( ) ;
URL . revokeObjectURL ( url ) ;
}
if ( uiModule ) uiModule . showToast ( ` Exported ${ _librarySelectedIds . size } document ${ _librarySelectedIds . size !== 1 ? 's' : '' } ` ) ;
}
/** Lazy-load SheetJS for spreadsheet parsing */
let _xlsxReady = null ;
function ensureXLSX ( ) {
if ( _xlsxReady ) return _xlsxReady ;
if ( window . XLSX ) return ( _xlsxReady = Promise . resolve ( ) ) ;
_xlsxReady = new Promise ( ( resolve , reject ) => {
const s = document . createElement ( 'script' ) ;
s . src = '/static/lib/xlsx.full.min.js' ;
s . onload = resolve ;
s . onerror = ( ) => reject ( new Error ( 'Failed to load XLSX library' ) ) ;
document . head . appendChild ( s ) ;
} ) ;
return _xlsxReady ;
}
let _mammothReady = null ;
function ensureMammoth ( ) {
if ( _mammothReady ) return _mammothReady ;
if ( window . mammoth ) return ( _mammothReady = Promise . resolve ( ) ) ;
_mammothReady = new Promise ( ( resolve , reject ) => {
const s = document . createElement ( 'script' ) ;
s . src = '/static/lib/mammoth.browser.min.js' ;
s . onload = resolve ;
s . onerror = ( ) => reject ( new Error ( 'Failed to load DOCX library' ) ) ;
document . head . appendChild ( s ) ;
} ) ;
return _mammothReady ;
}
/** Convert HTML from mammoth to clean markdown */
function htmlToMarkdown ( html ) {
const doc = new DOMParser ( ) . parseFromString ( html , 'text/html' ) ;
let md = '' ;
function walk ( node ) {
if ( node . nodeType === 3 ) { md += node . textContent ; return ; }
if ( node . nodeType !== 1 ) return ;
const tag = node . tagName . toLowerCase ( ) ;
if ( tag === 'h1' ) { md += '\n# ' ; walkChildren ( node ) ; md += '\n' ; }
else if ( tag === 'h2' ) { md += '\n## ' ; walkChildren ( node ) ; md += '\n' ; }
else if ( tag === 'h3' ) { md += '\n### ' ; walkChildren ( node ) ; md += '\n' ; }
else if ( tag === 'h4' ) { md += '\n#### ' ; walkChildren ( node ) ; md += '\n' ; }
else if ( tag === 'strong' || tag === 'b' ) { md += '**' ; walkChildren ( node ) ; md += '**' ; }
else if ( tag === 'em' || tag === 'i' ) { md += '*' ; walkChildren ( node ) ; md += '*' ; }
else if ( tag === 'a' ) { md += '[' ; walkChildren ( node ) ; md += ` ]( ${ node . href || '' } ) ` ; }
else if ( tag === 'br' ) { md += '\n' ; }
else if ( tag === 'p' ) { md += '\n' ; walkChildren ( node ) ; md += '\n' ; }
else if ( tag === 'ul' || tag === 'ol' ) { md += '\n' ; walkChildren ( node ) ; }
else if ( tag === 'li' ) {
const parent = node . parentElement ? . tagName ? . toLowerCase ( ) ;
if ( parent === 'ol' ) {
const idx = Array . from ( node . parentElement . children ) . indexOf ( node ) + 1 ;
md += ` ${ idx } . ` ;
} else { md += '- ' ; }
walkChildren ( node ) ;
md += '\n' ;
}
else if ( tag === 'table' ) { md += '\n' ; convertTable ( node ) ; md += '\n' ; }
else if ( tag === 'img' ) {
// Skip embedded base64 images — they produce huge unreadable blobs
const src = node . src || '' ;
if ( ! src . startsWith ( 'data:' ) ) {
md += `  ` ;
} else if ( node . alt ) {
md += ` *[image: ${ node . alt } ]* ` ;
}
}
else { walkChildren ( node ) ; }
}
function walkChildren ( node ) { for ( const child of node . childNodes ) walk ( child ) ; }
function convertTable ( table ) {
const rows = table . querySelectorAll ( 'tr' ) ;
rows . forEach ( ( tr , i ) => {
const cells = tr . querySelectorAll ( 'th, td' ) ;
md += '| ' + Array . from ( cells ) . map ( c => c . textContent . trim ( ) ) . join ( ' | ' ) + ' |\n' ;
if ( i === 0 ) md += '| ' + Array . from ( cells ) . map ( ( ) => '---' ) . join ( ' | ' ) + ' |\n' ;
} ) ;
}
walkChildren ( doc . body ) ;
return md . replace ( /\n{3,}/g , '\n\n' ) . trim ( ) ;
}
/** Read file contents — handles text, spreadsheet, and DOCX formats */
async function readFileContent ( file ) {
const name = file . name . toLowerCase ( ) ;
const isSpreadsheet = name . endsWith ( '.xlsx' ) || name . endsWith ( '.xls' ) || name . endsWith ( '.ods' ) ;
const isDocx = name . endsWith ( '.docx' ) ;
if ( isSpreadsheet ) {
await ensureXLSX ( ) ;
const buf = await file . arrayBuffer ( ) ;
const wb = window . XLSX . read ( buf , { type : 'array' } ) ;
// Convert each sheet to CSV, join with a header per sheet
const parts = [ ] ;
for ( const sheetName of wb . SheetNames ) {
if ( wb . SheetNames . length > 1 ) parts . push ( ` # Sheet: ${ sheetName } ` ) ;
parts . push ( window . XLSX . utils . sheet _to _csv ( wb . Sheets [ sheetName ] ) ) ;
}
return parts . join ( '\n\n' ) ;
}
if ( isDocx ) {
await ensureMammoth ( ) ;
const buf = await file . arrayBuffer ( ) ;
const result = await window . mammoth . convertToHtml ( { arrayBuffer : buf } ) ;
return htmlToMarkdown ( result . value ) ;
}
// Plain text
return new Promise ( ( resolve , reject ) => {
const reader = new FileReader ( ) ;
reader . onload = ( ) => resolve ( reader . result ) ;
reader . onerror = ( ) => reject ( reader . error ) ;
reader . readAsText ( file ) ;
} ) ;
}
/** Import files from disk into the document library */
async function libraryImportFiles ( fileList ) {
const EXT _TO _LANG = {
'.py' : 'python' , '.js' : 'javascript' , '.ts' : 'typescript' ,
'.html' : 'html' , '.htm' : 'html' , '.css' : 'css' , '.md' : 'markdown' ,
'.json' : 'json' , '.yml' : 'yaml' , '.yaml' : 'yaml' , '.sh' : 'bash' ,
'.bash' : 'bash' , '.sql' : 'sql' , '.rs' : 'rust' , '.go' : 'go' ,
'.java' : 'java' , '.c' : 'c' , '.cpp' : 'cpp' , '.h' : 'c' , '.hpp' : 'cpp' ,
'.rb' : 'ruby' , '.php' : 'php' , '.xml' : 'xml' ,
'.toml' : 'toml' , '.ini' : 'ini' , '.txt' : '' , '.log' : '' ,
'.cfg' : 'ini' , '.conf' : 'ini' , '.env' : '' , '.jsx' : 'javascript' ,
'.tsx' : 'typescript' , '.vue' : 'html' , '.svelte' : 'html' ,
'.scss' : 'css' , '.sass' : 'css' , '.less' : 'css' ,
'.csv' : 'csv' , '.tsv' : 'csv' ,
'.xlsx' : 'csv' , '.xls' : 'csv' , '.ods' : 'csv' ,
'.docx' : 'markdown' , '.doc' : 'markdown' ,
} ;
let imported = 0 ;
let failed = 0 ;
let _firstErr = '' ;
// Library imports aren't tied to a chat — the backend now accepts a
// session-less "library" document, so no session_id is sent.
for ( const file of fileList ) {
try {
const name = file . name ;
const dotIdx = name . lastIndexOf ( '.' ) ;
const ext = dotIdx >= 0 ? name . slice ( dotIdx ) . toLowerCase ( ) : '' ;
const baseTitle = dotIdx > 0 ? name . slice ( 0 , dotIdx ) : name ;
const language = EXT _TO _LANG [ ext ] !== undefined ? EXT _TO _LANG [ ext ] : null ;
const isSpreadsheet = [ '.xlsx' , '.xls' , '.ods' ] . includes ( ext ) ;
const isPdf = ext === '.pdf' ;
if ( isPdf ) {
// Backend handles save + AcroForm detection in one shot — picks the
// right doc kind so fillable forms get clickable inputs in the PDF
// view, and plain PDFs get the static page-image viewer.
const fd = new FormData ( ) ;
fd . append ( 'file' , file ) ;
const res = await fetch ( ` ${ API _BASE } /api/documents/import-pdf ` , {
method : 'POST' ,
body : fd ,
} ) ;
if ( ! res . ok ) {
let _e = ` HTTP ${ res . status } ` ;
try { const _j = await res . json ( ) ; _e = _j . detail || _j . error || _e ; } catch { }
throw new Error ( 'PDF import failed: ' + _e ) ;
}
imported ++ ;
continue ;
}
if ( isSpreadsheet ) {
// Multi-sheet: create one document per sheet
await ensureXLSX ( ) ;
const buf = await file . arrayBuffer ( ) ;
const wb = window . XLSX . read ( buf , { type : 'array' } ) ;
for ( const sheetName of wb . SheetNames ) {
const csv = window . XLSX . utils . sheet _to _csv ( wb . Sheets [ sheetName ] ) ;
if ( ! csv . trim ( ) ) continue ;
const sheetTitle = wb . SheetNames . length > 1
? ` ${ baseTitle } - ${ sheetName } ` : baseTitle ;
const res = await fetch ( ` ${ API _BASE } /api/document ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { title : sheetTitle , language : 'csv' , content : csv } ) ,
} ) ;
if ( ! res . ok ) throw new Error ( 'Server error' ) ;
}
imported ++ ;
} else {
const content = await readFileContent ( file ) ;
const res = await fetch ( ` ${ API _BASE } /api/document ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { title : baseTitle , language , content } ) ,
} ) ;
if ( ! res . ok ) throw new Error ( 'Server error' ) ;
imported ++ ;
}
} catch ( e ) {
console . error ( 'Failed to import file:' , file . name , e ) ;
if ( ! _firstErr ) _firstErr = ( e && e . message ) || String ( e ) ;
failed ++ ;
}
}
const msg = ` Imported ${ imported } file ${ imported !== 1 ? 's' : '' } ` +
( failed ? ` , ${ failed } failed ${ _firstErr ? ' — ' + _firstErr : '' } ` : '' ) ;
if ( failed && uiModule ) uiModule . showError ( msg ) ;
else if ( uiModule ) uiModule . showToast ( msg ) ;
await libraryFetch ( false ) ;
}
export function openLibrary ( opts ) {
if ( _libraryOpen ) {
// Recover from stuck state: the swipe-to-dismiss in ui.js adds .hidden
// to the modal without calling closeLibrary, so _libraryOpen can stay
// true even though the modal is gone or invisible. Detect and reset.
const existing = document . getElementById ( 'doclib-modal' ) ;
if ( ! existing || existing . classList . contains ( 'hidden' ) ) {
if ( existing ) existing . remove ( ) ;
_libraryOpen = false ;
} else {
return ;
}
}
_libraryOpen = true ;
_libraryImportMode = ! ! ( opts && opts . import ) ;
_librarySelectMode = false ;
_librarySelectedIds . clear ( ) ;
_librarySearch = '' ;
_libraryActiveLanguage = null ;
_librarySort = 'recent' ;
_libraryOffset = 0 ;
_libraryDocs = [ ] ;
// Create modal
const modal = document . createElement ( 'div' ) ;
modal . className = 'modal' ;
modal . id = 'doclib-modal' ;
modal . innerHTML = `
< div class = "modal-content doclib-modal-content" style = "width:min(640px, 92vw);max-height:85vh;background:var(--bg);" >
< div class = "modal-header" >
< h4 > < svg width = "16" height = "16" 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;" > < path d = "M4 19.5A2.5 2.5 0 0 1 6.5 17H20" / > < path d = "M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" / > < line x1 = "8" y1 = "7" x2 = "16" y2 = "7" / > < line x1 = "8" y1 = "11" x2 = "14" y2 = "11" / > < / s v g > L i b r a r y < / h 4 >
< button class = "close-btn" id = "doclib-close" > \u2716 < / b u t t o n >
< / d i v >
< div class = "lib-tabs" id = "doclib-lib-tabs" style = "padding:0 10px;" >
< 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" / > < / s v g > C h a t s < / b u t t o n >
< 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" / > < / s v g > D o c u m e n t s < / b u t t o n >
< 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" / > < / s v g > R e s e a r c h < / b u t t o n >
< 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" / > < / s v g > A r c h i v e < / b u t t o n >
< / d i v >
< div class = "modal-body" style = "display:flex;flex-direction:column;gap:10px;overflow:hidden;" >
< div id = "doclib-panel-chats" data - doclib - panel = "chats" class = "admin-card" style = "display:none;flex:1;flex-direction:column;overflow:hidden;" >
< div style = "display:flex;align-items:baseline;gap:8px;margin-bottom:2px;" >
< 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" > < / s p a n > < / h 2 >
< / d i v >
< p class = "memory-desc doclib-desc" > All active chat sessions . Click to open . < / p >
< div class = "memory-toolbar" >
< div class = "memory-category-filters" >
< select class = "memory-sort-select" id = "doclib-chats-sort" >
< option value = "recent" > Recent < / o p t i o n >
< option value = "oldest" > Oldest < / o p t i o n >
< option value = "most-messages" > Most messages < / o p t i o n >
< option value = "alpha" > A\u2013Z < / o p t i o n >
< / s e l e c t >
< button class = "memory-toolbar-btn" id = "doclib-chats-select-btn" > Select < / b u t t o n >
< button class = "memory-toolbar-btn" id = "doclib-chats-tidy-btn" title = "AI tidy: delete junk sessions and organize into folders" > < 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" / > < / s v g > T i d y < / b u t t o n >
< / d i v >
< input type = "text" id = "doclib-chats-search" placeholder = "Search chats\u2026" class = "memory-search-input" / >
< div id = "doclib-chats-chips" class = "doclib-lang-chips" > < / d i v >
< / d i v >
< div id = "doclib-chats-bulk" class = "memory-bulk-bar hidden" style = "margin-bottom:5px;" >
< 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 < / l a b e l >
< span id = "doclib-chats-selected-count" > 0 Selected < / s p a n >
< 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" / > < / s v g > A r c h i v e < / b u t t o n >
< button class = "memory-toolbar-btn danger" id = "doclib-chats-bulk-delete" style = "position:relative;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 = "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" / > < / s v g > D e l e t e < / b u t t o n >
< 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" / > < / s v g > < / b u t t o n >
< / d i v >
< div id = "doclib-chats-grid" class = "doclib-grid" > < / d i v >
< / d i v >
< div id = "doclib-panel-archive" data - doclib - panel = "archive" class = "admin-card" style = "display:none;flex:1;flex-direction:column;overflow:hidden;" >
< div style = "display:flex;align-items:baseline;gap:8px;margin-bottom:2px;" >
< 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" > < / s p a n > < / h 2 >
< / d i v >
< p class = "memory-desc doclib-desc" style = "position:relative;top:0.5px;" > Archived sessions . Restore to make active again . < / p >
< div class = "memory-toolbar" >
< div class = "memory-category-filters" >
< select class = "memory-sort-select" id = "doclib-arc-sort" >
< option value = "recent" > Recent < / o p t i o n >
< option value = "oldest" > Oldest < / o p t i o n >
< option value = "most-messages" > Most messages < / o p t i o n >
< option value = "alpha" > A\u2013Z < / o p t i o n >
< / s e l e c t >
< button class = "memory-toolbar-btn" id = "doclib-arc-select-btn" > Select < / b u t t o n >
< / d i v >
< input type = "text" id = "doclib-arc-search" placeholder = "Search archive\u2026" class = "memory-search-input" / >
< div id = "doclib-arc-chips" class = "doclib-lang-chips" > < / d i v >
< / d i v >
< div id = "doclib-arc-bulk" class = "memory-bulk-bar hidden" style = "margin-bottom:5px;" >
< label class = "memory-bulk-check-all" style = "position:relative;top:0px;left:1px;" > < input type = "checkbox" id = "doclib-arc-select-all" > All < / l a b e l >
< span id = "doclib-arc-selected-count" > 0 Selected < / s p a n >
< button class = "memory-toolbar-btn" id = "doclib-arc-bulk-restore" style = "position:relative;top:-3px;" > < 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" / > < / s v g > R e s t o r e < / b u t t o n >
< 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" / > < / s v g > D e l e t e < / b u t t o n >
< 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" / > < / s v g > < / b u t t o n >
< / d i v >
< div id = "doclib-arc-grid" class = "doclib-grid" > < / d i v >
< / d i v >
< div id = "doclib-panel-research" data - doclib - panel = "research" class = "admin-card" style = "display:none;flex:1;flex-direction:column;overflow:hidden;" >
< div style = "display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:10px;" >
< 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" > < / s p a n > < / h 2 >
< / d i v >
< p class = "memory-desc doclib-desc" style = "position:relative;top:-1px;" > Completed deep research reports . Click to view . < / p >
< div class = "memory-toolbar" >
< div class = "memory-category-filters" >
< select class = "memory-sort-select" id = "doclib-research-sort" >
< option value = "recent" > Recent < / o p t i o n >
< option value = "oldest" > Oldest < / o p t i o n >
< option value = "most-sources" > Most sources < / o p t i o n >
< option value = "alpha" > A\u2013Z < / o p t i o n >
< / s e l e c t >
< button class = "memory-toolbar-btn" id = "doclib-research-select-btn" > Select < / b u t t o n >
< button class = "memory-toolbar-btn" id = "doclib-research-tidy-btn" title = "Tidy: delete research with no sources or empty reports" > Tidy < / b u t t o n >
< / d i v >
< input type = "text" id = "doclib-research-search" placeholder = "Search research\u2026" class = "memory-search-input" / >
< / d i v >
< div id = "doclib-research-bulk" class = "memory-bulk-bar hidden" style = "margin-bottom:5px;" >
< label class = "memory-bulk-check-all" style = "position:relative;top:0px;left:1px;" > < input type = "checkbox" id = "doclib-research-select-all" > All < / l a b e l >
< span id = "doclib-research-selected-count" > 0 Selected < / s p a n >
< button class = "memory-toolbar-btn" id = "doclib-research-bulk-archive" 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 = "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" / > < / s v g > A r c h i v e < / b u t t o n >
< 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" / > < / s v g > D e l e t e < / b u t t o n >
< 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" / > < / s v g > < / b u t t o n >
< / d i v >
< div id = "doclib-research-grid" class = "doclib-grid" > < / d i v >
< / d i v >
< div data - doclib - panel = "documents" class = "admin-card" style = "flex:1;display:flex;flex-direction:column;overflow:hidden;" >
< div style = "display:flex;align-items:baseline;gap:8px;margin-bottom:2px;" >
< 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" > < / s p a n > < / h 2 >
< button class = "memory-toolbar-btn" id = "doclib-import-file-btn" title = "Import files from disk" style = "margin-left:auto;" > < 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" / > < / s v g > I m p o r t < / b u t t o n >
< 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" / > < / s v g > C r e a t e < / b u t t o n >
< / d i v >
< p class = "memory-desc doclib-desc" > Open documents in a session , clone to a new or import new files . < / p >
< div class = "memory-toolbar" >
< div class = "memory-category-filters" >
< select class = "memory-sort-select" id = "doclib-sort" >
< option value = "recent" > Recent < / o p t i o n >
< option value = "oldest" > Oldest < / o p t i o n >
< option value = "edits" > Most edits < / o p t i o n >
< option value = "alpha" > A\u2013Z < / o p t i o n >
< / s e l e c t >
< button class = "memory-toolbar-btn" id = "doclib-select-btn" title = "Select documents" > Select < / b u t t o n >
< button class = "memory-toolbar-btn" id = "doclib-tidy-btn" title = "Tidy: remove empty / junk / duplicate documents" > Tidy < / b u t t o n >
< / d i v >
< input type = "text" id = "doclib-search" placeholder = "Search titles & content\u2026" class = "memory-search-input" / >
< div id = "doclib-chips" class = "doclib-lang-chips" > < / d i v >
< / d i v >
< input type = "file" id = "doclib-file-input" multiple style = "display:none" / >
< div id = "doclib-bulk-bar" class = "memory-bulk-bar hidden" style = "margin-bottom:5px;" >
< label class = "memory-bulk-check-all" style = "position:relative;top:0px;left:1px;" > < input type = "checkbox" id = "doclib-select-all" / > All < / l a b e l >
< span id = "doclib-selected-count" > 0 Selected < / s p a n >
< button id = "doclib-bulk-actions" class = "memory-toolbar-btn" style = "position:relative;top:-2px;margin-left:auto;" > < 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" / > < / s v g > A c t i o n s < s p a n s t y l e = " o p a c i t y : 0 . 5 5 ; f o n t - s i z e : 9 p x ; " > & # 9 6 6 0 ; < / s p a n > < / b u t t o n >
< 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" / > < / s v g > < / b u t t o n >
< / d i v >
< div class = "doclib-grid" id = "doclib-grid" > < / d i v >
< button class = "doclib-load-more" id = "doclib-load-more" style = "display:none" > Load more < / b u t t o n >
< / d i v >
< / d i v >
< / d i v >
` ;
document . body . appendChild ( modal ) ;
// Make modal draggable (same logic as other modals)
{
const content = modal . querySelector ( '.modal-content' ) ;
const header = modal . querySelector ( '.modal-header' ) ;
if ( content && header ) {
// Restore saved position / fullscreen state
try {
const saved = JSON . parse ( localStorage . getItem ( 'doclib-pos' ) ) ;
if ( saved && saved . fullscreen ) {
localStorage . removeItem ( 'doclib-pos' ) ;
} else if ( saved && saved . left && saved . top ) {
content . style . position = 'fixed' ;
content . style . left = saved . left ;
content . style . top = saved . top ;
content . style . margin = '0' ;
// Clamp to viewport in case window was resized
requestAnimationFrame ( ( ) => {
const r = content . getBoundingClientRect ( ) ;
if ( r . right > window . innerWidth ) content . style . left = Math . max ( 0 , window . innerWidth - r . width - 8 ) + 'px' ;
if ( r . bottom > window . innerHeight ) content . style . top = Math . max ( 0 , window . innerHeight - r . height - 8 ) + 'px' ;
if ( r . left < 0 ) content . style . left = '8px' ;
if ( r . top < 0 ) content . style . top = '8px' ;
} ) ;
}
} catch { }
// Replaced ~150 lines of inline drag/snap/dock with one helper call.
// Library intentionally disables top-edge fullscreen snap: that layout
// breaks dense icon/tool rows. Side docking still works.
const FS _CLASS = 'doclib-fullscreen' ;
const enterFullscreen = ( ) => {
if ( modal . classList . contains ( FS _CLASS ) ) return ;
modal . classList . add ( FS _CLASS ) ;
content . style . position = 'fixed' ;
content . style . left = '0' ;
content . style . top = '0' ;
content . style . right = '0' ;
content . style . bottom = '0' ;
content . style . width = '100vw' ;
content . style . maxWidth = '100vw' ;
content . style . height = '100vh' ;
content . style . maxHeight = '100vh' ;
content . style . borderRadius = '0' ;
content . style . margin = '0' ;
content . style . transform = 'none' ;
try { localStorage . setItem ( 'doclib-pos' , JSON . stringify ( { fullscreen : true } ) ) ; } catch { }
} ;
const exitFullscreen = ( cx , cy ) => {
if ( ! modal . classList . contains ( FS _CLASS ) ) return ;
modal . classList . remove ( FS _CLASS ) ;
content . style . width = '' ;
content . style . maxWidth = '' ;
content . style . height = '' ;
content . style . maxHeight = '' ;
content . style . borderRadius = '' ;
content . style . right = '' ;
content . style . bottom = '' ;
const r0 = content . getBoundingClientRect ( ) ;
const w = r0 . width || Math . min ( 900 , window . innerWidth * 0.92 ) ;
content . style . left = Math . max ( 8 , cx - w / 2 ) + 'px' ;
content . style . top = Math . max ( 8 , cy - 20 ) + 'px' ;
} ;
makeWindowDraggable ( modal , {
content ,
header ,
fsClass : FS _CLASS ,
skipSelector : '.modal-close' ,
onEnterFullscreen : enterFullscreen ,
onExitFullscreen : exitFullscreen ,
enableFullscreen : false ,
onDragEnd : ( ) => {
try { localStorage . setItem ( 'doclib-pos' , JSON . stringify ( { left : content . style . left , top : content . style . top } ) ) ; } catch { }
} ,
} ) ;
}
}
// Wire events
document . getElementById ( 'doclib-close' ) . addEventListener ( 'click' , closeLibrary ) ;
// Tab switching — Chats / Documents / Archive / Research
let _activeLibTab = ( opts && opts . tab ) || 'documents' ;
const _tabBtns = modal . querySelectorAll ( '[data-doclib-tab]' ) ;
const _tabPanels = modal . querySelectorAll ( '[data-doclib-panel]' ) ;
// Client-side pagination for tabs whose API returns everything at once
// (chats/archive/research). Render only this many initially; the
// load-more button reveals more in chunks.
const _LIB _PAGE _SIZE = 20 ;
let _chatsVisibleLimit = _LIB _PAGE _SIZE ;
let _arcVisibleLimit = _LIB _PAGE _SIZE ;
let _researchVisibleLimit = _LIB _PAGE _SIZE ;
function _appendInlineLoadMore ( grid , totalCount , currentLimit , onClick ) {
if ( ! grid || ! grid . parentElement ) return ;
// Drop the previous instance (if any) — we re-render the list from
// scratch each pass, so the button is regenerated alongside it.
grid . parentElement . querySelectorAll ( ':scope > .doclib-inline-load-more' ) . forEach ( b => b . remove ( ) ) ;
if ( totalCount <= currentLimit ) return ;
const btn = document . createElement ( 'button' ) ;
btn . className = 'doclib-load-more doclib-inline-load-more' ;
btn . textContent = ` Load more ( ${ currentLimit } of ${ totalCount } ) ` ;
btn . addEventListener ( 'click' , onClick ) ;
grid . parentElement . appendChild ( btn ) ;
}
function _switchLibTab ( tab ) {
_activeLibTab = tab ;
_tabBtns . forEach ( b => b . classList . toggle ( 'active' , b . dataset . doclibTab === tab ) ) ;
_tabPanels . forEach ( p => {
if ( p . dataset . doclibPanel === tab ) {
p . style . display = 'flex' ;
} else {
p . style . display = 'none' ;
}
} ) ;
if ( tab === 'chats' ) _renderLibChats ( ) ;
else if ( tab === 'archive' ) _renderLibArchive ( ) ;
else if ( tab === 'research' ) _renderLibResearch ( ) ;
}
_tabBtns . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => _switchLibTab ( btn . dataset . doclibTab ) ) ;
} ) ;
// ── Chats tab state ──
let _chatsSessions = [ ] ;
let _chatsSearch = '' ;
let _chatsSort = 'recent' ;
let _chatsSelectMode = false ;
const _chatsSelected = new Set ( ) ;
let _chatsModelFilter = '' ;
function _renderLibChats ( ) {
const grid = document . getElementById ( 'doclib-chats-grid' ) ;
if ( ! grid ) return ;
grid . innerHTML = '' ;
grid . appendChild ( spinnerModule . createLoadingRow ( 'Loading…' ) ) ;
fetch ( API _BASE + '/api/sessions' , { credentials : 'same-origin' } ) . then ( r => r . json ( ) ) . then ( data => {
const raw = Array . isArray ( data ) ? data : ( data . sessions || [ ] ) ;
_chatsSessions = raw . filter ( s => ! s . archived ) ;
_renderChatsGrid ( ) ;
_renderChatsChips ( ) ;
} ) . catch ( ( ) => { grid . innerHTML = '<div class="doclib-empty">Failed to load</div>' ; } ) ;
}
// Tap a chat row to expand inline: fetches the recent messages and
// renders them as a preview with an "Open chat" button. Tap again to
// collapse. Mirrors the documents-tab expand pattern.
async function _toggleChatPreview ( card , session ) {
const preview = card . querySelector ( '.doclib-chat-preview' ) ;
if ( ! preview ) return ;
const isOpen = card . classList . contains ( 'doclib-card-expanded' ) ;
// Collapse any other open preview in this grid first
const grid = card . closest ( '.doclib-grid' ) ;
if ( grid ) {
grid . querySelectorAll ( '.doclib-card-expanded' ) . forEach ( c => {
if ( c !== card ) {
c . classList . remove ( 'doclib-card-expanded' ) ;
const p = c . querySelector ( '.doclib-chat-preview' ) ;
if ( p ) { p . style . display = 'none' ; p . innerHTML = '' ; }
}
} ) ;
}
if ( isOpen ) {
card . classList . remove ( 'doclib-card-expanded' ) ;
preview . style . display = 'none' ;
preview . innerHTML = '' ;
return ;
}
card . classList . add ( 'doclib-card-expanded' ) ;
preview . style . display = 'block' ;
preview . innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Loading…</div>' ;
try {
const res = await fetch ( ` ${ API _BASE } /api/history/ ${ session . id } ` , { credentials : 'same-origin' } ) ;
if ( ! res . ok ) throw new Error ( 'Failed' ) ;
const data = await res . json ( ) ;
const history = Array . isArray ( data ) ? data : ( data . history || [ ] ) ;
const recent = history . filter ( m => m . role === 'user' || m . role === 'assistant' ) . slice ( - 5 ) ;
const sessionModel = ( session . model || '' ) . split ( '/' ) . pop ( ) ;
const msgsHtml = recent . length
? recent . map ( m => {
const isUser = m . role === 'user' ;
const raw = m . content || '' ;
const truncated = raw . length > 600 ? raw . slice ( 0 , 600 ) + '…' : raw ;
// Strip thinking blocks (internal model state) and render with
// the same markdown pipeline the chat uses.
const cleaned = truncated
. replace ( /<think>[\s\S]*?<\/think>/g , '' )
. replace ( /<think>[\s\S]*$/ , '' )
. trim ( ) ;
let body ;
try {
body = markdownModule . mdToHtml ( cleaned ) ;
} catch { body = _esc ( cleaned ) ; }
// Per-message model can override the session default (e.g.
// when comparing models in the same chat).
const msgModel = ( m . metadata && ( m . metadata . model || m . metadata . model _name ) ) || '' ;
const modelTag = ! isUser && ( msgModel || sessionModel )
? ` <span class="doclib-chat-msg-model"> ${ _esc ( msgModel || sessionModel ) } </span> `
: '' ;
return ` <div class="doclib-chat-bubble-row ${ isUser ? 'user' : 'assistant' } ">
< div class = "doclib-chat-bubble" >
$ { modelTag }
< div class = "doclib-chat-bubble-body" > $ { body } < / d i v >
< / d i v >
< / d i v > ` ;
} ) . join ( '' )
: '<div style="opacity:0.4;font-size:11px;padding:6px 4px;">No messages yet</div>' ;
const isArchive = ! ! session . archived ;
// Archived chats get a Restore button (unarchive); active chats get the
// Archive button. Matches the research + document archive previews.
const archiveHtml = isArchive
? '<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>'
: '<button class="doclib-chat-archive-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="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>' ;
// Copy sits next to Archive on the left side of the action row.
// Uses the same border-only secondary-action style — distinct from
// the danger Delete (red) and the primary Open (right-aligned).
// Copy is hidden in the Archive (keep the footer to Delete + Restore +
// Open there). It still shows for active chats.
const copyHtml = isArchive ? '' : '<button class="doclib-chat-copy-btn">' +
'<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>' +
'Copy' +
'</button>' ;
const deleteHtml = '<button class="doclib-chat-delete-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="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>' ;
preview . innerHTML =
'<div class="doclib-chat-preview-messages">' + msgsHtml + '</div>' +
'<div class="doclib-chat-preview-actions">' +
deleteHtml +
archiveHtml +
copyHtml +
'<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>' +
'</div>' ;
const openBtn = preview . querySelector ( '.doclib-chat-open-btn' ) ;
if ( openBtn ) openBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
if ( window . sessionModule ) window . sessionModule . selectSession ( session . id ) ;
closeLibrary ( ) ;
// Also collapse the wide sidebar so the picked chat sits
// fullscreen — same gesture as picking a session in the
// sidebar itself on mobile. Skip on desktop where the user
// expects the sidebar to stay where they left it.
if ( window . innerWidth <= 768 ) {
const sb = document . getElementById ( 'sidebar' ) ;
if ( sb ) {
sb . classList . add ( 'hidden' ) ;
try { window . syncRailSide && window . syncRailSide ( ) ; } catch ( _ ) { }
}
}
} ) ;
const archiveBtn = preview . querySelector ( '.doclib-chat-archive-btn' ) ;
if ( archiveBtn ) archiveBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
await fetch ( API _BASE + '/api/session/' + session . id + '/archive' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
} ) ;
_renderLibChats ( ) ;
} ) ;
const restoreBtn = preview . querySelector ( '.doclib-chat-restore-btn' ) ;
if ( restoreBtn ) restoreBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
await fetch ( API _BASE + '/api/session/' + session . id + '/unarchive' , { method : 'POST' } ) ;
_renderLibArchive ( ) ;
} ) ;
const copyBtn = preview . querySelector ( '.doclib-chat-copy-btn' ) ;
if ( copyBtn ) copyBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
_copyChatById ( session . id ) ;
} ) ;
const deleteBtn = preview . querySelector ( '.doclib-chat-delete-btn' ) ;
if ( deleteBtn ) deleteBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
if ( ! await window . styledConfirm ( 'Delete this chat?' , { confirmText : 'Delete' , danger : true } ) ) return ;
await fetch ( API _BASE + '/api/session/' + session . id , { method : 'DELETE' } ) ;
card . style . maxHeight = ` ${ Math . max ( card . getBoundingClientRect ( ) . height , card . scrollHeight ) } px ` ;
card . classList . add ( 'memory-tidy-removing' ) ;
await new Promise ( r => setTimeout ( r , 520 ) ) ;
if ( isArchive ) _renderLibArchive ( ) ; else _renderLibChats ( ) ;
} ) ;
} catch ( e ) {
preview . innerHTML = '<div style="opacity:0.5;font-size:11px;padding:6px 4px;color:var(--color-error);">Failed to load preview</div>' ;
}
}
function _renderChatsGrid ( ) {
const grid = document . getElementById ( 'doclib-chats-grid' ) ;
if ( ! grid ) return ;
const _csb = document . getElementById ( 'doclib-chats-select-btn' ) ;
if ( _csb ) { _csb . classList . toggle ( 'active' , _chatsSelectMode ) ; _csb . textContent = _chatsSelectMode ? 'Cancel' : 'Select' ; }
let filtered = _chatsSessions . slice ( ) ;
if ( _chatsSearch ) {
const q = _chatsSearch . toLowerCase ( ) ;
filtered = filtered . filter ( s => ( s . name || '' ) . toLowerCase ( ) . includes ( q ) || ( s . model || '' ) . toLowerCase ( ) . includes ( q ) ) ;
}
if ( _chatsModelFilter ) filtered = filtered . filter ( s => s . folder === _chatsModelFilter ) ;
if ( _chatsSort === 'oldest' ) filtered . sort ( ( a , b ) => ( a . updated _at || '' ) > ( b . updated _at || '' ) ? 1 : - 1 ) ;
else if ( _chatsSort === 'most-messages' ) filtered . sort ( ( a , b ) => ( b . message _count || 0 ) - ( a . message _count || 0 ) ) ;
else if ( _chatsSort === 'alpha' ) filtered . sort ( ( a , b ) => ( a . name || '' ) . localeCompare ( b . name || '' ) ) ;
else filtered . sort ( ( a , b ) => ( b . updated _at || '' ) > ( a . updated _at || '' ) ? 1 : - 1 ) ;
const stats = document . getElementById ( 'doclib-chats-stats' ) ;
if ( stats ) stats . textContent = filtered . length + ' chat' + ( filtered . length !== 1 ? 's' : '' ) ;
if ( ! filtered . length ) {
// Sad-mouth smiley (downturn curve) for "nothing here yet".
const _sadIco = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule . emptyStateIcon ( 'sad' ) + '</span>' ;
grid . innerHTML = '<div class="doclib-empty">No chats' + _sadIco + '</div>' ;
_appendInlineLoadMore ( grid , 0 , _chatsVisibleLimit , ( ) => { } ) ;
return ;
}
const total = filtered . length ;
const visible = filtered . slice ( 0 , _chatsVisibleLimit ) ;
grid . innerHTML = '' ;
_maybeCascadeGrid ( grid , 'chats' ) ;
for ( const s of visible ) {
const card = document . createElement ( 'div' ) ;
card . className = 'memory-item doclib-chat-row' ;
card . style . cursor = 'pointer' ;
card . dataset . sid = s . id ;
const model = ( s . model || '' ) . split ( '/' ) . pop ( ) ;
const cbHtml = _chatsSelectMode ? '<input type="checkbox" class="memory-select-cb"' + ( _chatsSelected . has ( s . id ) ? ' checked' : '' ) + '>' : '' ;
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>' ;
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>' ;
// Msg count badge inside the title, dimmer than the name so it
// reads as metadata at a glance. Hidden when count is 0 so
// brand-new "New Chat" rows don't show "\u00b7 0 msgs".
const _chatMsgs = s . message _count || 0 ;
const msgCountHtml = _chatMsgs > 0
? '<span style="opacity:0.45;font-weight:normal;font-size:0.9em;margin-left:6px;">\u00b7 ' + _chatMsgs + ' msg' + ( _chatMsgs === 1 ? '' : 's' ) + '</span>'
: '' ;
card . innerHTML =
'<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' +
cbHtml +
'<div style="flex:1;min-width:0;">' +
'<div class="memory-item-title">' + chatIconSvg + _esc ( s . name || 'Untitled' ) + msgCountHtml + '</div>' +
'<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>' +
'</div>' +
chevronSvg +
'<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>' +
'</div>' +
'<div class="doclib-chat-preview" style="display:none;"></div>' ;
const cb = card . querySelector ( '.memory-select-cb' ) ;
if ( cb ) { cb . addEventListener ( 'click' , e => e . stopPropagation ( ) ) ; cb . addEventListener ( 'change' , ( ) => { if ( cb . checked ) _chatsSelected . add ( s . id ) ; else _chatsSelected . delete ( s . id ) ; _updateChatsCount ( ) ; } ) ; }
card . querySelector ( '._chat-menu' ) . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; _showLibDropdown ( e . currentTarget , [
{ label : 'Open' , action : ( ) => { if ( window . sessionModule ) window . sessionModule . selectSession ( s . id ) ; } } ,
{ label : 'Copy' , action : ( ) => _copyChatById ( s . id ) } ,
{ label : 'Archive' , action : async ( ) => { await fetch ( API _BASE + '/api/session/' + s . id + '/archive' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } } ) ; _renderLibChats ( ) ; } } ,
{ label : 'Delete' , action : async ( ) => {
await fetch ( API _BASE + '/api/session/' + s . id , { method : 'DELETE' } ) ;
card . style . maxHeight = ` ${ Math . max ( card . getBoundingClientRect ( ) . height , card . scrollHeight ) } px ` ;
card . classList . add ( 'memory-tidy-removing' ) ;
await new Promise ( r => setTimeout ( r , 520 ) ) ;
_renderLibChats ( ) ;
} , danger : true } ,
] , { onSelect : ( ) => {
_chatsSelectMode = true ;
_chatsSelected . add ( s . id ) ;
document . getElementById ( 'doclib-chats-bulk' ) ? . classList . remove ( 'hidden' ) ;
_renderChatsGrid ( ) ;
} } ) ; } ) ;
card . addEventListener ( 'click' , ( e ) => {
if ( card . _suppressNextClick ) { card . _suppressNextClick = false ; return ; }
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 ; }
if ( e . target . closest ( '._chat-menu' ) || e . target . closest ( '.memory-select-cb' ) || e . target . closest ( '.doclib-chat-open-btn' ) ) return ;
_toggleChatPreview ( card , s ) ;
} ) ;
_attachLongPressMenu ( card , '._chat-menu' ) ;
grid . appendChild ( card ) ;
}
_appendInlineLoadMore ( grid , total , _chatsVisibleLimit , ( ) => {
_chatsVisibleLimit += _LIB _PAGE _SIZE ;
_renderChatsGrid ( ) ;
} ) ;
}
function _renderChatsChips ( ) {
const el = document . getElementById ( 'doclib-chats-chips' ) ;
if ( ! el ) return ;
const counts = { } ;
_chatsSessions . forEach ( s => { const f = s . folder ; if ( f ) counts [ f ] = ( counts [ f ] || 0 ) + 1 ; } ) ;
const folders = Object . keys ( counts ) . sort ( ) ;
if ( folders . length < 1 ) { el . innerHTML = '' ; return ; }
el . innerHTML = '' ;
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 ) ; } ;
mk ( 'all' , '' , _chatsSessions . length ) ;
folders . forEach ( f => mk ( f , f , counts [ f ] ) ) ;
}
function _updateChatsCount ( ) { const el = document . getElementById ( 'doclib-chats-selected-count' ) ; if ( el ) el . textContent = _chatsSelected . size + ' Selected' ; }
// Chats event listeners
document . getElementById ( 'doclib-chats-sort' ) . addEventListener ( 'change' , ( e ) => { _chatsSort = e . target . value ; _renderChatsGrid ( ) ; } ) ;
document . getElementById ( 'doclib-chats-search' ) . addEventListener ( 'input' , ( e ) => { _chatsSearch = e . target . value . trim ( ) ; _renderChatsGrid ( ) ; } ) ;
document . getElementById ( 'doclib-chats-select-btn' ) . addEventListener ( 'click' , ( ) => { _chatsSelectMode = ! _chatsSelectMode ; _chatsSelected . clear ( ) ; document . getElementById ( 'doclib-chats-bulk' ) . classList . toggle ( 'hidden' , ! _chatsSelectMode ) ; _renderChatsGrid ( ) ; } ) ;
document . getElementById ( 'doclib-chats-bulk-cancel' ) ? . addEventListener ( 'click' , ( ) => {
_chatsSelectMode = false ; _chatsSelected . clear ( ) ;
document . getElementById ( 'doclib-chats-bulk' ) . classList . add ( 'hidden' ) ;
_renderChatsGrid ( ) ;
} ) ;
function _chatsToggleAll ( ) {
const allCb = document . getElementById ( 'doclib-chats-select-all' ) ;
const newState = _chatsSelected . size < _chatsSessions . length ;
if ( allCb ) allCb . checked = newState ;
document . querySelectorAll ( '#doclib-chats-grid .memory-select-cb' ) . forEach ( cb => { cb . checked = newState ; } ) ;
_chatsSessions . forEach ( s => { if ( newState ) _chatsSelected . add ( s . id ) ; else _chatsSelected . delete ( s . id ) ; } ) ;
_updateChatsCount ( ) ;
}
document . getElementById ( 'doclib-chats-select-all' ) . addEventListener ( 'change' , _chatsToggleAll ) ;
document . getElementById ( 'doclib-chats-bulk' ) . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( 'button' ) || e . target . closest ( 'input' ) ) return ;
_chatsToggleAll ( ) ;
} ) ;
document . getElementById ( 'doclib-chats-bulk-archive' ) . addEventListener ( 'click' , async ( ) => {
const count = _chatsSelected . size ;
if ( ! count ) return ;
const grid = document . getElementById ( 'doclib-chats-grid' ) ;
if ( grid ) {
grid . querySelectorAll ( '.doclib-card' ) . forEach ( card => {
const sid = card . dataset . sid || card . dataset . sessionId ;
if ( sid && _chatsSelected . has ( sid ) ) {
card . style . transition = 'opacity 0.25s, transform 0.25s' ;
card . style . opacity = '0' ;
card . style . transform = 'scale(0.95)' ;
}
} ) ;
}
await new Promise ( r => setTimeout ( r , 250 ) ) ;
const ids = [ ... _chatsSelected ] ;
const results = await Promise . all (
ids . map ( sid => fetch ( API _BASE + '/api/session/' + sid + '/archive' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } } )
. then ( r => ( { sid , ok : r . ok } ) )
. catch ( ( ) => ( { sid , ok : false } ) )
)
) ;
const failed = results . filter ( r => ! r . ok ) . map ( r => r . sid ) ;
if ( failed . length && grid ) {
grid . querySelectorAll ( '.doclib-card' ) . forEach ( card => {
const sid = card . dataset . sid || card . dataset . sessionId ;
if ( sid && failed . includes ( sid ) ) {
card . style . opacity = '' ;
card . style . transform = '' ;
}
} ) ;
if ( window . uiModule ) window . uiModule . showError ( ` Failed to archive ${ failed . length } of ${ ids . length } chat ${ ids . length > 1 ? 's' : '' } ` ) ;
}
_chatsSelected . clear ( ) ;
_chatsSelectMode = false ;
document . getElementById ( 'doclib-chats-bulk' ) . classList . add ( 'hidden' ) ;
_renderLibChats ( ) ;
} ) ;
document . getElementById ( 'doclib-chats-bulk-delete' ) . addEventListener ( 'click' , async ( ) => {
const count = _chatsSelected . size ;
if ( ! count ) return ;
if ( ! await window . styledConfirm ( ` Delete ${ count } chat ${ count > 1 ? 's' : '' } ? This cannot be undone. ` , { confirmText : 'Delete' , danger : true } ) ) return ;
// Fade out selected cards
const grid = document . getElementById ( 'doclib-chats-grid' ) ;
if ( grid ) {
grid . querySelectorAll ( '.doclib-card' ) . forEach ( card => {
const sid = card . dataset . sid || card . dataset . sessionId ;
if ( sid && _chatsSelected . has ( sid ) ) {
card . style . transition = 'opacity 0.25s, transform 0.25s' ;
card . style . opacity = '0' ;
card . style . transform = 'scale(0.95)' ;
}
} ) ;
}
// Delete after animation. v2 review HIGH-8: inspect each response
// so cards that the server rejected get restored (instead of
// staying faded out forever) and the user sees an aggregate
// error toast.
await new Promise ( r => setTimeout ( r , 250 ) ) ;
const ids = [ ... _chatsSelected ] ;
const results = await Promise . all (
ids . map ( sid => fetch ( API _BASE + '/api/session/' + sid , { method : 'DELETE' } )
. then ( r => ( { sid , ok : r . ok } ) )
. catch ( ( ) => ( { sid , ok : false } ) )
)
) ;
const failed = results . filter ( r => ! r . ok ) . map ( r => r . sid ) ;
if ( failed . length && grid ) {
// Restore faded cards for the rows the server refused.
grid . querySelectorAll ( '.doclib-card' ) . forEach ( card => {
const sid = card . dataset . sid || card . dataset . sessionId ;
if ( sid && failed . includes ( sid ) ) {
card . style . opacity = '' ;
card . style . transform = '' ;
}
} ) ;
if ( window . uiModule ) window . uiModule . showError ( ` Failed to delete ${ failed . length } of ${ ids . length } chat ${ ids . length > 1 ? 's' : '' } ` ) ;
}
_chatsSelected . clear ( ) ;
_chatsSelectMode = false ;
document . getElementById ( 'doclib-chats-bulk' ) . classList . add ( 'hidden' ) ;
_renderLibChats ( ) ;
} ) ;
// Tidy button — AI cleanup + organize into folders
document . getElementById ( 'doclib-chats-tidy-btn' ) . addEventListener ( 'click' , async ( ) => {
const tidyBtn = document . getElementById ( 'doclib-chats-tidy-btn' ) ;
const origHTML = tidyBtn . innerHTML ;
tidyBtn . disabled = true ;
tidyBtn . classList . add ( 'spinning' ) ;
tidyBtn . textContent = '' ;
// Silent whirlpool, nudged up to line up with the surrounding button
// text in the Chats header. The previous version checked
// `window.spinnerModule` (never bound) and always fell through to a
// plain "Tidying..." label.
const sp = spinnerModule . create ( '' , 'clean' , 'whirlpool' ) ;
const el = sp . createElement ( ) ;
el . style . position = 'relative' ;
el . style . top = '1px' ;
tidyBtn . appendChild ( el ) ;
sp . start ( ) ;
try {
const res = await fetch ( API _BASE + '/api/sessions/auto-sort' , { method : 'POST' , credentials : 'same-origin' } ) ;
const data = await res . json ( ) ;
if ( ! res . ok ) throw new Error ( data . detail || 'Tidy failed' ) ;
if ( data . status === 'ok' ) {
if ( window . uiModule ) window . uiModule . showToast ( 'Sorted ' + data . updated + ' sessions into ' + data . folders . length + ' folders' ) ;
if ( window . sessionModule ) await window . sessionModule . loadSessions ( ) ;
_renderLibChats ( ) ;
} else {
if ( window . uiModule ) window . uiModule . showToast ( data . reason || 'Nothing to tidy' ) ;
}
} catch ( e ) {
if ( window . uiModule ) window . uiModule . showError ( 'Tidy: ' + e . message ) ;
} finally {
tidyBtn . disabled = false ;
tidyBtn . classList . remove ( 'spinning' ) ;
tidyBtn . innerHTML = origHTML ;
}
} ) ;
// ── Archive tab state ──
let _arcSessions = [ ] ;
let _arcDocs = [ ] ; // archived documents
let _arcResearch = [ ] ; // archived research reports
let _arcSearch = '' ;
let _arcSort = 'recent' ;
let _arcSelectMode = false ;
const _arcSelected = new Set ( ) ;
let _arcModelFilter = '' ;
let _arcTypeFilter = '' ; // '', 'chats', 'documents', 'research'
function _renderLibArchive ( ) {
const grid = document . getElementById ( 'doclib-arc-grid' ) ;
if ( ! grid ) return ;
grid . innerHTML = '' ;
grid . appendChild ( spinnerModule . createLoadingRow ( 'Loading…' ) ) ;
// Archive tab is the home for ALL archived items — chats, documents, and
// research — each rendered with its own icon. Load the three in parallel.
Promise . all ( [
fetch ( API _BASE + '/api/sessions/archived?limit=100&sort=recent' , { credentials : 'same-origin' } ) . then ( r => r . json ( ) ) . catch ( ( ) => ( { } ) ) ,
fetch ( API _BASE + '/api/documents/library?archived=true&limit=50' , { credentials : 'same-origin' } ) . then ( r => r . json ( ) ) . catch ( ( ) => ( { } ) ) ,
fetch ( '/api/research/library?archived=true' , { credentials : 'same-origin' } ) . then ( r => r . json ( ) ) . catch ( ( ) => ( { } ) ) ,
] ) . then ( ( [ s , d , r ] ) => {
// These are all archived by definition — flag them so the expanded
// chat preview hides its (redundant) "Archive" button.
_arcSessions = ( s . sessions || [ ] ) . map ( x => ( { ... x , archived : true } ) ) ;
_arcDocs = d . documents || [ ] ;
_arcResearch = ( r . research || [ ] ) . map ( x => ( { ... x , archived : true } ) ) ;
_renderArcGrid ( ) ;
_renderArcChips ( ) ;
} ) . catch ( ( ) => { grid . innerHTML = '<div class="doclib-empty">Failed to load</div>' ; } ) ;
}
// Inline expand/collapse for an archived DOCUMENT card (chat-style). Loads
// the doc content into the card's .doclib-chat-preview. Lag-safe: caps the
// shown text and skips highlighting (archived previews are read-only peeks).
async function _toggleArcDocPreview ( card , d ) {
const preview = card . querySelector ( '.doclib-chat-preview' ) ;
if ( ! preview ) return ;
const grid = card . closest ( '.doclib-grid' ) ;
if ( grid ) {
grid . querySelectorAll ( '.doclib-card-expanded' ) . forEach ( c => {
if ( c !== card ) {
c . classList . remove ( 'doclib-card-expanded' ) ;
const p = c . querySelector ( '.doclib-chat-preview' ) ;
if ( p ) { p . style . display = 'none' ; p . innerHTML = '' ; }
}
} ) ;
}
if ( card . classList . contains ( 'doclib-card-expanded' ) ) {
card . classList . remove ( 'doclib-card-expanded' ) ;
preview . style . display = 'none' ; preview . innerHTML = '' ;
return ;
}
card . classList . add ( 'doclib-card-expanded' ) ;
preview . style . display = 'block' ;
preview . innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Loading…</div>' ;
try {
const res = await fetch ( ` ${ API _BASE } /api/document/ ${ d . id } ` , { credentials : 'same-origin' } ) ;
if ( ! res . ok ) throw new Error ( 'failed' ) ;
const full = await res . json ( ) ;
const content = ( full . current _content || '' ) . slice ( 0 , 20000 ) ;
const pre = document . createElement ( 'pre' ) ;
pre . style . cssText = 'white-space:pre-wrap;word-break:break-word;font-size:11px;margin:6px 4px;max-height:50vh;overflow:auto;' ;
pre . textContent = content || '(empty document)' ;
preview . innerHTML = '' ;
preview . appendChild ( pre ) ;
// Footer — uses the same visible .doclib-chat-preview-actions style as
// the chat/research previews (the .doclib-card-expanded-actions class is
// display:none unless inside a .doclib-card, which these archive rows
// are not). Delete + Restore, matching the others.
const actions = document . createElement ( 'div' ) ;
actions . className = 'doclib-chat-preview-actions' ;
actions . innerHTML =
'<button class="doclib-chat-delete-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="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>' +
'<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>' +
'<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>' ;
actions . querySelector ( '.doclib-chat-delete-btn' ) . addEventListener ( 'click' , async ( ev ) => {
ev . stopPropagation ( ) ;
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 ( ) ;
} ) ;
actions . querySelector ( '.doclib-chat-restore-btn' ) . addEventListener ( 'click' , async ( ev ) => {
ev . stopPropagation ( ) ;
await fetch ( ` ${ API _BASE } /api/document/ ${ d . id } /archive?archived=false ` , { method : 'POST' , credentials : 'same-origin' } ) ;
_renderLibArchive ( ) ;
} ) ;
// Open = clone the doc into the active session and surface it in the editor.
actions . querySelector ( '.doclib-chat-open-btn' ) . addEventListener ( 'click' , ( ev ) => {
ev . stopPropagation ( ) ;
libraryImportDocument ( d ) ;
} ) ;
preview . appendChild ( actions ) ;
} catch {
preview . innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Failed to load preview</div>' ;
}
}
function _renderArcGrid ( ) {
const grid = document . getElementById ( 'doclib-arc-grid' ) ;
if ( ! grid ) return ;
const _asb = document . getElementById ( 'doclib-arc-select-btn' ) ;
if ( _asb ) { _asb . classList . toggle ( 'active' , _arcSelectMode ) ; _asb . textContent = _arcSelectMode ? 'Cancel' : 'Select' ; }
let filtered = _arcSessions . slice ( ) ;
if ( _arcSearch ) {
const q = _arcSearch . toLowerCase ( ) ;
filtered = filtered . filter ( s => ( s . name || '' ) . toLowerCase ( ) . includes ( q ) || ( s . model || '' ) . toLowerCase ( ) . includes ( q ) ) ;
}
if ( _arcModelFilter ) filtered = filtered . filter ( s => ( s . model || '' ) . split ( '/' ) . pop ( ) === _arcModelFilter ) ;
if ( _arcSort === 'oldest' ) filtered . sort ( ( a , b ) => ( a . updated _at || '' ) > ( b . updated _at || '' ) ? 1 : - 1 ) ;
else if ( _arcSort === 'most-messages' ) filtered . sort ( ( a , b ) => ( b . message _count || 0 ) - ( a . message _count || 0 ) ) ;
else if ( _arcSort === 'alpha' ) filtered . sort ( ( a , b ) => ( a . name || '' ) . localeCompare ( b . name || '' ) ) ;
else filtered . sort ( ( a , b ) => ( b . updated _at || '' ) > ( a . updated _at || '' ) ? 1 : - 1 ) ;
// Archived documents + research also live here — filter them by the same search.
const _aq = ( _arcSearch || '' ) . toLowerCase ( ) ;
let filtDocs = _aq ? _arcDocs . filter ( d => ( d . title || '' ) . toLowerCase ( ) . includes ( _aq ) ) : _arcDocs ;
let filtResearch = _aq ? _arcResearch . filter ( r => ( r . query || '' ) . toLowerCase ( ) . includes ( _aq ) ) : _arcResearch ;
// Type filter chips (Chats / Documents / Research) zero out the others.
const _showChats = ! _arcTypeFilter || _arcTypeFilter === 'chats' ;
const _showDocs = ! _arcTypeFilter || _arcTypeFilter === 'documents' ;
const _showResearch = ! _arcTypeFilter || _arcTypeFilter === 'research' ;
if ( ! _showChats ) filtered = [ ] ;
if ( ! _showDocs ) filtDocs = [ ] ;
if ( ! _showResearch ) filtResearch = [ ] ;
const stats = document . getElementById ( 'doclib-arc-stats' ) ;
if ( stats ) stats . textContent = ( filtered . length + filtDocs . length + filtResearch . length ) + ' archived' ;
if ( ! filtered . length && ! filtDocs . length && ! filtResearch . length ) {
// Neutral / no-smile face for "nothing archived here".
const _neutralIco = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule . emptyStateIcon ( 'neutral' ) + '</span>' ;
grid . innerHTML = '<div class="doclib-empty">No archived items' + _neutralIco + '</div>' ;
_appendInlineLoadMore ( grid , 0 , _arcVisibleLimit , ( ) => { } ) ;
return ;
}
const total = filtered . length ;
const visible = filtered . slice ( 0 , _arcVisibleLimit ) ;
grid . innerHTML = '' ;
_maybeCascadeGrid ( grid , 'archive' ) ;
for ( const s of visible ) {
const card = document . createElement ( 'div' ) ;
card . className = 'memory-item doclib-chat-row' ;
card . style . cursor = 'pointer' ;
card . dataset . sid = s . id ;
card . dataset . arckey = 'chats:' + s . id ;
const model = ( s . model || '' ) . split ( '/' ) . pop ( ) ;
const cbHtml = _arcSelectMode ? '<input type="checkbox" class="memory-select-cb" data-arckey="chats:' + s . id + '"' + ( _arcSelected . has ( 'chats:' + s . id ) ? ' checked' : '' ) + '>' : '' ;
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>' ;
card . innerHTML =
'<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' +
cbHtml +
'<div style="flex:1;min-width:0;">' +
'<div class="memory-item-title">' + arcIconSvg + _esc ( s . name || 'Untitled' ) + '</div>' +
'<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>' +
'</div>' +
'<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>' +
'</div>' +
'<div class="doclib-chat-preview" style="display:none;"></div>' ;
const cb = card . querySelector ( '.memory-select-cb' ) ;
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 ( ) ; } ) ; }
card . querySelector ( '._arc-menu' ) . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; _showLibDropdown ( e . currentTarget , [
{ label : 'Open' , action : ( ) => { if ( window . sessionModule ) window . sessionModule . selectSession ( s . id ) ; } } ,
{ label : 'Copy' , action : ( ) => _copyChatById ( s . id ) } ,
{ label : 'Restore' , action : async ( ) => { await fetch ( API _BASE + '/api/session/' + s . id + '/unarchive' , { method : 'POST' } ) ; _renderLibArchive ( ) ; } } ,
{ label : 'Delete' , action : async ( ) => { await fetch ( API _BASE + '/api/session/' + s . id , { method : 'DELETE' } ) ; _renderLibArchive ( ) ; } , danger : true } ,
] , { onSelect : ( ) => {
_arcSelectMode = true ;
_arcSelected . add ( 'chats:' + s . id ) ;
document . getElementById ( 'doclib-arc-bulk' ) ? . classList . remove ( 'hidden' ) ;
_renderArcGrid ( ) ;
} } ) ; } ) ;
card . addEventListener ( 'click' , ( e ) => {
if ( card . _suppressNextClick ) { card . _suppressNextClick = false ; return ; }
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 ; }
if ( e . target . closest ( '._arc-menu' ) || e . target . closest ( '.memory-select-cb' ) || e . target . closest ( '.doclib-chat-open-btn' ) ) return ;
_toggleChatPreview ( card , s ) ;
} ) ;
_attachLongPressMenu ( card , '._arc-menu' ) ;
grid . appendChild ( card ) ;
}
// Archived DOCUMENTS — document icon, Restore / Delete.
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>' ;
for ( const d of filtDocs ) {
const card = document . createElement ( 'div' ) ;
card . className = 'memory-item doclib-chat-row' ;
card . style . cursor = 'pointer' ;
card . dataset . arckey = 'documents:' + d . id ;
const _dcb = _arcSelectMode ? '<input type="checkbox" class="memory-select-cb" data-arckey="documents:' + d . id + '"' + ( _arcSelected . has ( 'documents:' + d . id ) ? ' checked' : '' ) + '>' : '' ;
card . innerHTML =
'<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' +
_dcb +
'<div style="flex:1;min-width:0;">' +
'<div class="memory-item-title">' + _arcDocIco + _esc ( d . title || 'Untitled' ) + '</div>' +
'<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>' +
'</div>' +
'<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>' +
'</div>' +
'<div class="doclib-chat-preview" style="display:none;"></div>' ;
const _dcbEl = card . querySelector ( '.memory-select-cb' ) ;
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 ( ) ; } ) ; }
card . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '._arc-doc-menu' ) || e . target . closest ( '.memory-select-cb' ) ) return ;
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 ; }
_toggleArcDocPreview ( card , d ) ;
} ) ;
card . querySelector ( '._arc-doc-menu' ) . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; _showLibDropdown ( e . currentTarget , [
{ label : 'Restore' , action : async ( ) => { await fetch ( API _BASE + '/api/document/' + d . id + '/archive?archived=false' , { method : 'POST' , credentials : 'same-origin' } ) ; _renderLibArchive ( ) ; } } ,
{ 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 ( ) ; } } ,
] , { onSelect : ( ) => {
_arcSelectMode = true ;
_arcSelected . add ( 'documents:' + d . id ) ;
document . getElementById ( 'doclib-arc-bulk' ) ? . classList . remove ( 'hidden' ) ;
_renderArcGrid ( ) ;
} } ) ; } ) ;
_attachLongPressMenu ( card , '._arc-doc-menu' ) ;
grid . appendChild ( card ) ;
}
// Archived RESEARCH — magnifier icon, Open / Restore / Delete.
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>' ;
for ( const r of filtResearch ) {
const card = document . createElement ( 'div' ) ;
card . className = 'memory-item doclib-chat-row' ;
card . style . cursor = 'pointer' ;
card . dataset . arckey = 'research:' + r . id ;
const _rcb = _arcSelectMode ? '<input type="checkbox" class="memory-select-cb" data-arckey="research:' + r . id + '"' + ( _arcSelected . has ( 'research:' + r . id ) ? ' checked' : '' ) + '>' : '' ;
card . innerHTML =
'<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' +
_rcb +
'<div style="flex:1;min-width:0;">' +
'<div class="memory-item-title">' + _arcResIco + _esc ( r . query || 'Research' ) + '</div>' +
'<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>' +
'</div>' +
'<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>' +
'</div>' +
'<div class="doclib-chat-preview" style="display:none;"></div>' ;
const _rcbEl = card . querySelector ( '.memory-select-cb' ) ;
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 ( ) ; } ) ; }
card . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '._arc-res-menu' ) || e . target . closest ( '.memory-select-cb' ) ) return ;
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 ; }
_toggleResearchPreview ( card , r ) ;
} ) ;
card . querySelector ( '._arc-res-menu' ) . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; _showLibDropdown ( e . currentTarget , [
{ 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 ( ) ; } } ,
{ label : 'Restore' , action : async ( ) => { await fetch ( '/api/research/' + r . id + '/archive?archived=false' , { method : 'POST' , credentials : 'same-origin' } ) ; _renderLibArchive ( ) ; } } ,
{ 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 ( ) ; } } ,
] , { onSelect : ( ) => {
_arcSelectMode = true ;
_arcSelected . add ( 'research:' + r . id ) ;
document . getElementById ( 'doclib-arc-bulk' ) ? . classList . remove ( 'hidden' ) ;
_renderArcGrid ( ) ;
} } ) ; } ) ;
_attachLongPressMenu ( card , '._arc-res-menu' ) ;
grid . appendChild ( card ) ;
}
_appendInlineLoadMore ( grid , total , _arcVisibleLimit , ( ) => {
_arcVisibleLimit += _LIB _PAGE _SIZE ;
_renderArcGrid ( ) ;
} ) ;
}
function _renderArcChips ( ) {
const el = document . getElementById ( 'doclib-arc-chips' ) ;
if ( ! el ) return ;
// Type filters: All / Chats / Documents / Research (only the ones present).
el . innerHTML = '' ;
const mk = ( label , val , count ) => {
const c = document . createElement ( 'button' ) ;
c . className = 'memory-cat-chip' + ( _arcTypeFilter === val ? ' active' : '' ) ;
c . textContent = label + ' (' + count + ')' ;
c . addEventListener ( 'click' , ( ) => { _arcTypeFilter = _arcTypeFilter === val ? '' : val ; _renderArcGrid ( ) ; _renderArcChips ( ) ; } ) ;
el . appendChild ( c ) ;
} ;
const total = _arcSessions . length + _arcDocs . length + _arcResearch . length ;
if ( ! total ) return ;
mk ( 'All' , '' , total ) ;
if ( _arcSessions . length ) mk ( 'Chats' , 'chats' , _arcSessions . length ) ;
if ( _arcDocs . length ) mk ( 'Documents' , 'documents' , _arcDocs . length ) ;
if ( _arcResearch . length ) mk ( 'Research' , 'research' , _arcResearch . length ) ;
}
function _updateArcCount ( ) { const el = document . getElementById ( 'doclib-arc-selected-count' ) ; if ( el ) el . textContent = _arcSelected . size + ' Selected' ; }
// Archive event listeners
document . getElementById ( 'doclib-arc-sort' ) . addEventListener ( 'change' , ( e ) => { _arcSort = e . target . value ; _renderArcGrid ( ) ; } ) ;
document . getElementById ( 'doclib-arc-search' ) . addEventListener ( 'input' , ( e ) => { _arcSearch = e . target . value . trim ( ) ; _renderArcGrid ( ) ; } ) ;
document . getElementById ( 'doclib-arc-select-btn' ) . addEventListener ( 'click' , ( ) => { _arcSelectMode = ! _arcSelectMode ; _arcSelected . clear ( ) ; document . getElementById ( 'doclib-arc-bulk' ) . classList . toggle ( 'hidden' , ! _arcSelectMode ) ; _renderArcGrid ( ) ; } ) ;
document . getElementById ( 'doclib-arc-bulk-cancel' ) ? . addEventListener ( 'click' , ( ) => {
_arcSelectMode = false ; _arcSelected . clear ( ) ;
document . getElementById ( 'doclib-arc-bulk' ) . classList . add ( 'hidden' ) ;
_renderArcGrid ( ) ;
} ) ;
// Select-all toggles EVERY visible archived card (chats + docs + research),
// keyed by the card's composite "type:id" data-arckey.
function _arcToggleAll ( ) {
const cbs = document . querySelectorAll ( '#doclib-arc-grid .memory-select-cb' ) ;
const newState = _arcSelected . size < cbs . length ;
const allCb = document . getElementById ( 'doclib-arc-select-all' ) ;
if ( allCb ) allCb . checked = newState ;
cbs . forEach ( cb => {
cb . checked = newState ;
const k = cb . dataset . arckey ;
if ( k ) { if ( newState ) _arcSelected . add ( k ) ; else _arcSelected . delete ( k ) ; }
} ) ;
_updateArcCount ( ) ;
}
document . getElementById ( 'doclib-arc-select-all' ) . addEventListener ( 'change' , _arcToggleAll ) ;
document . getElementById ( 'doclib-arc-bulk' ) . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( 'button' ) || e . target . closest ( 'input' ) ) return ;
_arcToggleAll ( ) ;
} ) ;
// Route a composite "type:id" key to the right restore / delete endpoint.
function _arcRestoreOne ( key ) {
const i = key . indexOf ( ':' ) , type = key . slice ( 0 , i ) , id = key . slice ( i + 1 ) ;
if ( type === 'documents' ) return fetch ( API _BASE + '/api/document/' + id + '/archive?archived=false' , { method : 'POST' , credentials : 'same-origin' } ) ;
if ( type === 'research' ) return fetch ( '/api/research/' + id + '/archive?archived=false' , { method : 'POST' , credentials : 'same-origin' } ) ;
return fetch ( API _BASE + '/api/session/' + id + '/unarchive' , { method : 'POST' , credentials : 'same-origin' } ) ;
}
function _arcDeleteOne ( key ) {
const i = key . indexOf ( ':' ) , type = key . slice ( 0 , i ) , id = key . slice ( i + 1 ) ;
if ( type === 'documents' ) return fetch ( API _BASE + '/api/document/' + id , { method : 'DELETE' , credentials : 'same-origin' } ) ;
if ( type === 'research' ) return fetch ( '/api/research/' + id , { method : 'DELETE' , credentials : 'same-origin' } ) ;
return fetch ( API _BASE + '/api/session/' + id , { method : 'DELETE' , credentials : 'same-origin' } ) ;
}
document . getElementById ( 'doclib-arc-bulk-restore' ) . addEventListener ( 'click' , async ( ) => {
if ( ! _arcSelected . size ) return ;
await Promise . all ( [ ... _arcSelected ] . map ( _arcRestoreOne ) ) ;
_arcSelected . clear ( ) ; _arcSelectMode = false ;
document . getElementById ( 'doclib-arc-bulk' ) . classList . add ( 'hidden' ) ;
_renderLibArchive ( ) ;
} ) ;
document . getElementById ( 'doclib-arc-bulk-delete' ) . addEventListener ( 'click' , async ( ) => {
const count = _arcSelected . size ;
if ( ! count ) return ;
if ( ! await window . styledConfirm ( ` Delete ${ count } archived item ${ count > 1 ? 's' : '' } permanently? ` , { confirmText : 'Delete' , danger : true } ) ) return ;
const grid = document . getElementById ( 'doclib-arc-grid' ) ;
if ( grid ) {
grid . querySelectorAll ( '.memory-item[data-arckey]' ) . forEach ( card => {
if ( _arcSelected . has ( card . dataset . arckey ) ) {
card . style . transition = 'opacity 0.25s, transform 0.25s' ;
card . style . opacity = '0' ;
card . style . transform = 'scale(0.95)' ;
}
} ) ;
}
await new Promise ( r => setTimeout ( r , 250 ) ) ;
await Promise . all ( [ ... _arcSelected ] . map ( _arcDeleteOne ) ) ;
_arcSelected . clear ( ) ;
_arcSelectMode = false ;
document . getElementById ( 'doclib-arc-bulk' ) . classList . add ( 'hidden' ) ;
_renderLibArchive ( ) ;
} ) ;
// ── Research tab ──
let _researchItems = [ ] ;
let _researchSearch = '' ;
let _researchSelectMode = false ;
let _researchArchivedView = false ;
const _researchSelected = new Set ( ) ;
async function _renderLibResearch ( ) {
const grid = document . getElementById ( 'doclib-research-grid' ) ;
const stats = document . getElementById ( 'doclib-research-stats' ) ;
if ( ! grid ) return ;
// Show our whirlpool spinner instead of the plain "Loading..." text.
grid . innerHTML = '' ;
try {
const _spm = ( await import ( './spinner.js' ) ) . default ;
const _sp = _spm . createWhirlpool ( 22 ) ;
_sp . element . style . cssText = 'margin:18px auto;display:block;' ;
grid . appendChild ( _sp . element ) ;
} catch { grid . innerHTML = '<div class="hwfit-loading">Loading…</div>' ; }
try {
const res = await fetch ( '/api/research/library' + ( _researchArchivedView ? '?archived=true' : '' ) , { credentials : 'same-origin' } ) ;
if ( ! res . ok ) throw new Error ( res . statusText ) ;
const data = await res . json ( ) ;
_researchItems = data . research || data || [ ] ;
} catch ( e ) {
grid . innerHTML = ` <div class="hwfit-loading">Failed to load: ${ e . message } </div> ` ;
return ;
}
_renderResearchGrid ( ) ;
}
// Toggle inline preview for a research row. Mirrors _toggleChatPreview
// but pulls research-specific metadata: query, sources list (truncated),
// followed by an "Open" action that loads the full report.
async function _toggleResearchPreview ( card , item ) {
const preview = card . querySelector ( '.doclib-chat-preview' ) ;
if ( ! preview ) return ;
const isOpen = card . classList . contains ( 'doclib-card-expanded' ) ;
const grid = card . closest ( '.doclib-grid' ) ;
if ( grid ) {
grid . querySelectorAll ( '.doclib-card-expanded' ) . forEach ( c => {
if ( c !== card ) {
c . classList . remove ( 'doclib-card-expanded' ) ;
const p = c . querySelector ( '.doclib-chat-preview' ) ;
if ( p ) { p . style . display = 'none' ; p . innerHTML = '' ; }
}
} ) ;
}
if ( isOpen ) {
card . classList . remove ( 'doclib-card-expanded' ) ;
preview . style . display = 'none' ;
preview . innerHTML = '' ;
return ;
}
card . classList . add ( 'doclib-card-expanded' ) ;
preview . style . display = 'block' ;
preview . innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Loading…</div>' ;
let detail = item ;
try {
// Hit the per-research detail endpoint to pull sources + summary.
// The library list endpoint only returns lightweight metadata.
const res = await fetch ( ` ${ API _BASE } /api/research/detail/ ${ item . id } ` , { credentials : 'same-origin' } ) ;
if ( res . ok ) detail = await res . json ( ) ;
} catch { }
const sources = Array . isArray ( detail . sources ) ? detail . sources : [ ] ;
const sourcesList = sources . slice ( 0 , 12 ) . map ( ( src , i ) => {
const title = _esc ( src . title || src . url || ` Source ${ i + 1 } ` ) ;
const url = src . url || '' ;
return url
? ` <li><a href=" ${ _esc ( url ) } " target="_blank" rel="noopener"> ${ title } </a></li> `
: ` <li> ${ title } </li> ` ;
} ) . join ( '' ) ;
const sourcesHtml = sources . length
? ` <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> `
: '' ;
// The stored research JSON keeps the report under `result` (clean) /
// `raw_report` — there's no `summary` field, so the preview was empty.
const summary = ( detail . summary || detail . report _summary || detail . result || detail . raw _report || '' ) . toString ( ) . trim ( ) ;
const summaryHtml = summary
? ` <div class="doclib-research-summary"><div class="doclib-research-section-label">Report</div><div> ${ markdownModule . mdToHtml ? markdownModule . mdToHtml ( summary ) : _esc ( summary ) } </div></div> `
: '' ;
preview . innerHTML =
'<div class="doclib-chat-preview-messages">' +
( summaryHtml || sourcesHtml || '<div style="opacity:0.4;font-size:11px;padding:6px 4px;">No preview available</div>' ) +
( summaryHtml && sourcesHtml ? sourcesHtml : '' ) +
'</div>' +
'<div class="doclib-chat-preview-actions">' +
'<button class="doclib-chat-delete-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="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>' +
'<button class="doclib-chat-archive-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="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>' +
( ( _researchArchivedView || item . archived ) ? 'Restore' : 'Archive' ) +
'</button>' +
// Discuss is hidden in the Archive so the footer matches chat
// (Delete + Restore + Open).
( item . archived ? '' :
'<button class="doclib-chat-discuss-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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>' +
'Discuss' +
'</button>' ) +
'<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>' +
'</div>' ;
const discussBtn = preview . querySelector ( '.doclib-chat-discuss-btn' ) ;
if ( discussBtn ) discussBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
const _orig = discussBtn . innerHTML ;
discussBtn . disabled = true ;
discussBtn . textContent = 'Creating…' ;
try {
const _sid = detail . session _id || detail . id || item . id ;
const res = await fetch ( ` ${ API _BASE } /api/research/spinoff/ ${ _sid } ` , { method : 'POST' , credentials : 'same-origin' } ) ;
if ( ! res . ok ) { let d = '' ; try { d = ( await res . json ( ) ) . detail || '' ; } catch { } throw new Error ( d || ( 'HTTP ' + res . status ) ) ; }
const payload = await res . json ( ) ;
if ( window . sessionModule && payload . session _id ) {
await window . sessionModule . loadSessions ( ) . catch ( ( ) => { } ) ;
await window . sessionModule . selectSession ( payload . session _id ) ;
}
closeLibrary ( ) ;
} catch ( err ) {
discussBtn . disabled = false ;
discussBtn . innerHTML = _orig ;
if ( uiModule ) uiModule . showError ( 'Could not start discussion: ' + ( err . message || err ) ) ;
}
} ) ;
const openBtn = preview . querySelector ( '.doclib-chat-open-btn' ) ;
if ( openBtn ) openBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
const a = document . createElement ( 'a' ) ;
a . href = '/api/research/report/' + item . id ;
a . target = '_blank' ;
a . rel = 'noopener' ;
document . body . appendChild ( a ) ;
a . click ( ) ;
a . remove ( ) ;
} ) ;
const delBtn = preview . querySelector ( '.doclib-chat-delete-btn' ) ;
if ( delBtn ) delBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
const ok = uiModule && uiModule . styledConfirm
? await uiModule . styledConfirm ( 'Delete this research report?' , { confirmText : 'Delete' , danger : true } )
: window . confirm ( 'Delete this research report?' ) ;
if ( ! ok ) return ;
try {
const res = await fetch ( ` ${ API _BASE } /api/research/ ${ item . id } ` , { method : 'DELETE' , credentials : 'same-origin' } ) ;
if ( ! res . ok ) throw new Error ( await res . text ( ) ) ;
if ( item . archived ) {
_renderLibArchive ( ) ;
} else {
_researchItems = _researchItems . filter ( r => r . id !== item . id ) ;
_renderResearchGrid ( ) ;
}
} catch ( err ) {
if ( uiModule && uiModule . showError ) uiModule . showError ( 'Failed to delete: ' + err . message ) ;
}
} ) ;
const arcBtn = preview . querySelector ( '.doclib-chat-archive-btn' ) ;
if ( arcBtn ) arcBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
// From the main Archive tab the item is already archived → Restore and
// refresh the archive. From the Research tab, toggle as before.
const fromArchiveTab = ! ! item . archived ;
const toArchived = fromArchiveTab ? false : ! _researchArchivedView ;
try {
await fetch ( ` ${ API _BASE } /api/research/ ${ item . id } /archive?archived= ${ toArchived } ` , { method : 'POST' , credentials : 'same-origin' } ) ;
if ( fromArchiveTab ) {
_renderLibArchive ( ) ;
} else {
_researchItems = _researchItems . filter ( r => r . id !== item . id ) ;
_renderResearchGrid ( ) ;
}
if ( uiModule ) uiModule . showToast ( toArchived ? 'Archived' : 'Restored' ) ;
} catch { if ( uiModule ) uiModule . showError ( 'Failed to ' + ( toArchived ? 'archive' : 'restore' ) ) ; }
} ) ;
}
function _renderResearchGrid ( ) {
const grid = document . getElementById ( 'doclib-research-grid' ) ;
const stats = document . getElementById ( 'doclib-research-stats' ) ;
if ( ! grid ) return ;
const _rsb = document . getElementById ( 'doclib-research-select-btn' ) ;
if ( _rsb ) { _rsb . classList . toggle ( 'active' , _researchSelectMode ) ; _rsb . textContent = _researchSelectMode ? 'Cancel' : 'Select' ; }
let items = _researchItems ;
if ( _researchSearch ) {
const s = _researchSearch . toLowerCase ( ) ;
items = items . filter ( r => ( r . query || '' ) . toLowerCase ( ) . includes ( s ) ) ;
}
// Sort
const _rSort = document . getElementById ( 'doclib-research-sort' ) ? . value || 'recent' ;
if ( _rSort === 'recent' ) items . sort ( ( a , b ) => ( b . completed _at || 0 ) - ( a . completed _at || 0 ) ) ;
else if ( _rSort === 'oldest' ) items . sort ( ( a , b ) => ( a . completed _at || 0 ) - ( b . completed _at || 0 ) ) ;
else if ( _rSort === 'most-sources' ) items . sort ( ( a , b ) => ( b . source _count || 0 ) - ( a . source _count || 0 ) ) ;
else if ( _rSort === 'alpha' ) items . sort ( ( a , b ) => ( a . query || '' ) . localeCompare ( b . query || '' ) ) ;
if ( stats ) stats . textContent = items . length + ' research' + ( items . length !== 1 ? 'es' : '' ) ;
if ( ! items . length ) {
grid . innerHTML =
'<div class="hwfit-loading" style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;">' +
'<span>No research yet</span>' +
'<span style="opacity:0.7;font-size:11px;">' +
'create one in the <a href="#" data-doclib-open-research style="color:var(--accent,var(--red));text-decoration:underline;">Deep Research</a> tab' +
'</span>' +
'</div>' ;
grid . querySelector ( '[data-doclib-open-research]' ) ? . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
document . getElementById ( 'rail-research' ) ? . click ( ) ;
} ) ;
_appendInlineLoadMore ( grid , 0 , _researchVisibleLimit , ( ) => { } ) ;
return ;
}
const total = items . length ;
items = items . slice ( 0 , _researchVisibleLimit ) ;
let html = '' ;
for ( const r of items ) {
const date = r . completed _at ? new Date ( r . completed _at * 1000 ) . toLocaleDateString ( undefined , { month : 'short' , day : 'numeric' , year : 'numeric' } ) : '' ;
const time = r . completed _at ? new Date ( r . completed _at * 1000 ) . toLocaleTimeString ( undefined , { hour : '2-digit' , minute : '2-digit' } ) : '' ;
const sources = r . source _count || 0 ;
const duration = r . duration || '' ;
const rounds = r . rounds || '' ;
const selected = _researchSelected . has ( r . id ) ;
const metaBits = [ ] ;
if ( date ) metaBits . push ( ` ${ date } ${ time } ` ) ;
if ( sources ) metaBits . push ( ` ${ sources } sources ` ) ;
if ( rounds ) metaBits . push ( ` ${ rounds } rounds ` ) ;
if ( duration ) metaBits . push ( ` ${ duration } ` ) ;
const metaText = metaBits . join ( ' \u00B7 ' ) ;
html += ` <div class="memory-item doclib-chat-row doclib-research-card" data-research-id=" ${ r . id } " style="cursor:pointer;"> ` ;
html += ` <div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;"> ` ;
if ( _researchSelectMode ) html += ` <input type="checkbox" class="memory-select-cb _res-cb" data-rid=" ${ r . id } " ${ selected ? ' checked' : '' } > ` ;
html += ` <div style="flex:1;min-width:0;"> ` ;
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> ` ;
html += ` <div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;"> ${ metaText } </div> ` ;
html += ` </div> ` ;
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> ` ;
html += ` </div> ` ;
html += ` <div class="doclib-chat-preview" style="display:none;"></div> ` ;
html += ` </div> ` ;
}
grid . innerHTML = html ;
_maybeCascadeGrid ( grid , 'research' ) ;
// Wire checkboxes
grid . querySelectorAll ( '._res-cb' ) . forEach ( cb => {
cb . addEventListener ( 'click' , e => e . stopPropagation ( ) ) ;
cb . addEventListener ( 'change' , ( ) => {
if ( cb . checked ) _researchSelected . add ( cb . dataset . rid ) ; else _researchSelected . delete ( cb . dataset . rid ) ;
_updateResearchCount ( ) ;
} ) ;
} ) ;
// Click card → toggle preview (chat-style expand). The menu button
// and Open-report button inside the preview are exempt.
grid . querySelectorAll ( '.doclib-research-card' ) . forEach ( card => {
card . addEventListener ( 'click' , ( e ) => {
if ( card . _suppressNextClick ) { card . _suppressNextClick = false ; return ; }
if ( e . target . closest ( '.doclib-research-delete' ) || e . target . closest ( '._res-cb' ) || e . target . closest ( '.doclib-chat-open-btn' ) ) return ;
const rid = card . dataset . researchId ;
if ( _researchSelectMode ) {
const cb = card . querySelector ( '._res-cb' ) ;
if ( cb ) { cb . checked = ! cb . checked ; cb . dispatchEvent ( new Event ( 'change' ) ) ; }
return ;
}
const item = _researchItems . find ( r => r . id === rid ) ;
if ( item ) _toggleResearchPreview ( card , item ) ;
} ) ;
_attachLongPressMenu ( card , '.doclib-research-delete' ) ;
} ) ;
// The action button on each research row opens the actions menu
// (Open report, Delete) — chat-style ••• menu.
grid . querySelectorAll ( '.doclib-research-delete' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
const rid = btn . dataset . rid ;
_showLibDropdown ( btn , [
{ label : 'Open' , action : ( ) => {
const a = document . createElement ( 'a' ) ;
a . href = '/api/research/report/' + rid ;
a . target = '_blank' ;
a . rel = 'noopener' ;
document . body . appendChild ( a ) ;
a . click ( ) ;
a . remove ( ) ;
} } ,
{ label : _researchArchivedView ? 'Restore' : 'Archive' , action : async ( ) => {
const toArchived = ! _researchArchivedView ;
const card = btn . closest ( '.doclib-research-card' ) ;
if ( card ) { card . style . transition = 'opacity 0.25s, transform 0.25s' ; card . style . opacity = '0' ; card . style . transform = 'scale(0.95)' ; }
try { await fetch ( '/api/research/' + rid + '/archive?archived=' + toArchived , { method : 'POST' , credentials : 'same-origin' } ) ; } catch { }
await new Promise ( r => setTimeout ( r , 200 ) ) ;
_researchItems = _researchItems . filter ( r => r . id !== rid ) ;
_renderResearchGrid ( ) ;
if ( uiModule ) uiModule . showToast ( toArchived ? 'Archived' : 'Restored' ) ;
} } ,
{ label : 'Delete' , danger : true , action : async ( ) => {
if ( ! await window . styledConfirm ( 'Delete this research?' , { confirmText : 'Delete' , danger : true } ) ) return ;
const card = btn . closest ( '.doclib-research-card' ) ;
if ( card ) {
card . style . transition = 'opacity 0.25s, transform 0.25s' ;
card . style . opacity = '0' ;
card . style . transform = 'scale(0.95)' ;
}
await new Promise ( r => setTimeout ( r , 250 ) ) ;
await fetch ( '/api/research/' + rid , { method : 'DELETE' , credentials : 'same-origin' } ) ;
_researchItems = _researchItems . filter ( r => r . id !== rid ) ;
_renderResearchGrid ( ) ;
} } ,
] , { onSelect : ( ) => {
_researchSelectMode = true ;
_researchSelected . add ( rid ) ;
document . getElementById ( 'doclib-research-bulk' ) ? . classList . remove ( 'hidden' ) ;
_renderResearchGrid ( ) ;
} } ) ;
} ) ;
} ) ;
_appendInlineLoadMore ( grid , total , _researchVisibleLimit , ( ) => {
_researchVisibleLimit += _LIB _PAGE _SIZE ;
_renderResearchGrid ( ) ;
} ) ;
}
// Research sort + search
const researchSortEl = document . getElementById ( 'doclib-research-sort' ) ;
if ( researchSortEl ) researchSortEl . addEventListener ( 'change' , ( ) => _renderResearchGrid ( ) ) ;
const researchSearchEl = document . getElementById ( 'doclib-research-search' ) ;
if ( researchSearchEl ) {
researchSearchEl . addEventListener ( 'input' , ( ) => {
_researchSearch = researchSearchEl . value . trim ( ) ;
_renderResearchGrid ( ) ;
} ) ;
}
function _updateResearchCount ( ) {
const el = document . getElementById ( 'doclib-research-selected-count' ) ;
if ( el ) el . textContent = _researchSelected . size + ' Selected' ;
const arc = document . getElementById ( 'doclib-research-bulk-archive' ) ;
if ( arc ) arc . textContent = _researchArchivedView ? 'Restore' : 'Archive' ;
}
// Research select mode
document . getElementById ( 'doclib-research-select-btn' ) ? . addEventListener ( 'click' , ( ) => {
_researchSelectMode = ! _researchSelectMode ;
_researchSelected . clear ( ) ;
document . getElementById ( 'doclib-research-bulk' ) . classList . toggle ( 'hidden' , ! _researchSelectMode ) ;
_renderResearchGrid ( ) ;
} ) ;
// Research tidy — delete reports that came back empty (no sources, or
// empty report body). Matches the Chats tidy whirlpool/borderless pattern
// and skips confirmation per user request.
document . getElementById ( 'doclib-research-tidy-btn' ) ? . addEventListener ( 'click' , async ( e ) => {
const btn = e . currentTarget ;
const origHTML = btn . innerHTML ;
btn . disabled = true ;
btn . classList . add ( 'spinning' ) ;
btn . textContent = '' ;
const sp = spinnerModule . create ( '' , 'clean' , 'whirlpool' ) ;
const el = sp . createElement ( ) ;
el . style . position = 'relative' ;
el . style . top = '1px' ;
btn . appendChild ( el ) ;
sp . start ( ) ;
try {
const candidates = [ ] ;
const needFetch = [ ] ;
for ( const r of _researchItems ) {
if ( ( r . source _count || 0 ) === 0 ) candidates . push ( r ) ;
else needFetch . push ( r ) ;
}
const results = await Promise . all ( needFetch . map ( async r => {
try {
const res = await fetch ( '/api/research/detail/' + r . id , { credentials : 'same-origin' } ) ;
if ( ! res . ok ) return null ;
const d = await res . json ( ) ;
// Backend JSON uses `result` (rendered) or `raw_report` (raw md).
// If neither exists or both are tiny, treat as empty.
const body = ( d . result || d . raw _report || '' ) . trim ( ) ;
return body . length < 200 ? r : null ;
} catch { return null ; }
} ) ) ;
for ( const r of results ) if ( r ) candidates . push ( r ) ;
if ( candidates . length === 0 ) {
if ( uiModule ) uiModule . showToast ( 'Nothing to tidy' ) ;
return ;
}
await Promise . all ( candidates . map ( r => fetch ( '/api/research/' + r . id , { method : 'DELETE' , credentials : 'same-origin' } ) . catch ( ( ) => { } ) ) ) ;
const ids = new Set ( candidates . map ( r => r . id ) ) ;
_researchItems = _researchItems . filter ( r => ! ids . has ( r . id ) ) ;
_renderResearchGrid ( ) ;
if ( uiModule ) uiModule . showToast ( 'Deleted ' + candidates . length ) ;
} finally {
sp . stop ( ) ;
btn . disabled = false ;
btn . classList . remove ( 'spinning' ) ;
btn . innerHTML = origHTML ;
}
} ) ;
document . getElementById ( 'doclib-research-archived-btn' ) ? . addEventListener ( 'click' , ( e ) => {
_researchArchivedView = ! _researchArchivedView ;
e . currentTarget . classList . toggle ( 'active' , _researchArchivedView ) ;
e . currentTarget . title = _researchArchivedView ? 'Show active research' : 'Show archived research' ;
if ( _researchSelectMode ) { _researchSelectMode = false ; _researchSelected . clear ( ) ; document . getElementById ( 'doclib-research-bulk' ) . classList . add ( 'hidden' ) ; }
_renderLibResearch ( ) ;
} ) ;
document . getElementById ( 'doclib-research-bulk-cancel' ) ? . addEventListener ( 'click' , ( ) => {
_researchSelectMode = false ;
_researchSelected . clear ( ) ;
document . getElementById ( 'doclib-research-bulk' ) . classList . add ( 'hidden' ) ;
_renderResearchGrid ( ) ;
} ) ;
// Research select all
document . getElementById ( 'doclib-research-select-all' ) ? . addEventListener ( 'change' , ( ) => {
const allCb = document . getElementById ( 'doclib-research-select-all' ) ;
const newState = allCb ? . checked ;
_researchItems . forEach ( r => { if ( newState ) _researchSelected . add ( r . id ) ; else _researchSelected . delete ( r . id ) ; } ) ;
_updateResearchCount ( ) ;
_renderResearchGrid ( ) ;
} ) ;
// Research bulk delete
document . getElementById ( 'doclib-research-bulk-delete' ) ? . addEventListener ( 'click' , async ( ) => {
const count = _researchSelected . size ;
if ( ! count ) return ;
if ( ! await window . styledConfirm ( ` Delete ${ count } research report ${ count > 1 ? 's' : '' } permanently? ` , { confirmText : 'Delete' , danger : true } ) ) return ;
const grid = document . getElementById ( 'doclib-research-grid' ) ;
if ( grid ) {
grid . querySelectorAll ( '.doclib-research-card' ) . forEach ( card => {
if ( _researchSelected . has ( card . dataset . researchId ) ) {
card . style . transition = 'opacity 0.25s, transform 0.25s' ;
card . style . opacity = '0' ;
card . style . transform = 'scale(0.95)' ;
}
} ) ;
}
await new Promise ( r => setTimeout ( r , 250 ) ) ;
await Promise . all ( [ ... _researchSelected ] . map ( rid => fetch ( '/api/research/' + rid , { method : 'DELETE' , credentials : 'same-origin' } ) ) ) ;
_researchItems = _researchItems . filter ( r => ! _researchSelected . has ( r . id ) ) ;
_researchSelected . clear ( ) ;
_researchSelectMode = false ;
document . getElementById ( 'doclib-research-bulk' ) . classList . add ( 'hidden' ) ;
_renderResearchGrid ( ) ;
} ) ;
// Research bulk archive / restore
document . getElementById ( 'doclib-research-bulk-archive' ) ? . addEventListener ( 'click' , async ( ) => {
const count = _researchSelected . size ;
if ( ! count ) return ;
const toArchived = ! _researchArchivedView ;
const grid = document . getElementById ( 'doclib-research-grid' ) ;
if ( grid ) {
grid . querySelectorAll ( '.doclib-research-card' ) . forEach ( card => {
if ( _researchSelected . has ( card . dataset . researchId ) ) {
card . style . transition = 'opacity 0.25s, transform 0.25s' ;
card . style . opacity = '0' ;
card . style . transform = 'scale(0.95)' ;
}
} ) ;
}
await new Promise ( r => setTimeout ( r , 250 ) ) ;
await Promise . all ( [ ... _researchSelected ] . map ( rid => fetch ( '/api/research/' + rid + '/archive?archived=' + toArchived , { method : 'POST' , credentials : 'same-origin' } ) ) ) ;
_researchItems = _researchItems . filter ( r => ! _researchSelected . has ( r . id ) ) ;
_researchSelected . clear ( ) ;
_researchSelectMode = false ;
document . getElementById ( 'doclib-research-bulk' ) . classList . add ( 'hidden' ) ;
_renderResearchGrid ( ) ;
if ( uiModule ) uiModule . showToast ( toArchived ? 'Archived' : 'Restored' ) ;
} ) ;
// Shared dropdown for chats/archive menus — defined at module scope below
// (was here originally; hoisted so libraryCreateCard's mobile kebab
// handler — which lives outside openLibrary's closure — can call it).
function _relTime ( iso ) {
if ( ! iso ) return '' ;
const diff = Date . now ( ) - new Date ( iso ) . getTime ( ) ;
const mins = Math . floor ( diff / 60000 ) ;
if ( mins < 60 ) return mins + 'm ago' ;
const hrs = Math . floor ( mins / 60 ) ;
if ( hrs < 24 ) return hrs + 'h ago' ;
const days = Math . floor ( hrs / 24 ) ;
if ( days < 30 ) return days + 'd ago' ;
return new Date ( iso ) . toLocaleDateString ( ) ;
}
// Switch to initial tab if not documents
if ( _activeLibTab !== 'documents' ) _switchLibTab ( _activeLibTab ) ;
const searchInput = document . getElementById ( 'doclib-search' ) ;
searchInput . addEventListener ( 'input' , ( ) => {
clearTimeout ( _librarySearchDebounce ) ;
_librarySearchDebounce = setTimeout ( ( ) => {
_librarySearch = searchInput . value . trim ( ) ;
libraryFetch ( false ) ;
} , 300 ) ;
} ) ;
document . getElementById ( 'doclib-sort' ) . addEventListener ( 'change' , ( e ) => {
_librarySort = e . target . value ;
libraryFetch ( false ) ;
} ) ;
document . getElementById ( 'doclib-load-more' ) . addEventListener ( 'click' , ( ) => {
_libraryOffset = _libraryDocs . length ;
libraryFetch ( true ) ;
} ) ;
// Show "Load more" only when scrolled near bottom
const grid = document . getElementById ( 'doclib-grid' ) ;
if ( grid ) {
grid . addEventListener ( 'scroll' , ( ) => libraryRenderLoadMore ( ) ) ;
// Auto-fill on resize (fullscreen toggle, window resize, sidebar
// toggle): re-run the load-more check so newly-revealed empty
// space below the last card pulls in the next page automatically.
if ( typeof ResizeObserver !== 'undefined' ) {
new ResizeObserver ( ( ) => libraryRenderLoadMore ( ) ) . observe ( grid ) ;
}
}
// Wire file import button
const importFileBtn = document . getElementById ( 'doclib-import-file-btn' ) ;
const fileInput = document . getElementById ( 'doclib-file-input' ) ;
if ( importFileBtn && fileInput ) {
importFileBtn . addEventListener ( 'click' , ( ) => fileInput . click ( ) ) ;
fileInput . addEventListener ( 'change' , async ( ) => {
if ( fileInput . files . length === 0 ) return ;
const files = fileInput . files ;
fileInput . value = '' ;
// Swap the import icon for a whirlpool while files upload.
const _orig = importFileBtn . innerHTML ;
importFileBtn . disabled = true ;
let _sp = null ;
try {
_sp = spinnerModule . createWhirlpool ( 12 ) ;
_sp . element . style . cssText = 'width:12px;height:12px;margin:0 4px 0 0;display:inline-block;vertical-align:middle;position:relative;top:-2px;' ;
importFileBtn . innerHTML = '' ;
importFileBtn . appendChild ( _sp . element ) ;
importFileBtn . appendChild ( document . createTextNode ( 'Import' ) ) ;
} catch { }
try {
await libraryImportFiles ( files ) ;
} finally {
try { _sp && _sp . stop ( ) ; } catch { }
importFileBtn . innerHTML = _orig ;
importFileBtn . disabled = false ;
}
} ) ;
}
// Create button — new blank document
const createBtn = document . getElementById ( 'doclib-create-btn' ) ;
if ( createBtn ) {
createBtn . addEventListener ( 'click' , async ( ) => {
// Create a new session, then create a blank document in it
try {
const sRes = await fetch ( '/api/session' , { method : 'POST' , credentials : 'same-origin' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( { title : 'Untitled Document' } ) } ) ;
const sData = await sRes . json ( ) ;
const sessionId = sData . session _id ;
await _createDocument ( sessionId ) ;
// Close library and open the new session
closeLibrary ( ) ;
if ( window . sessionsModule ) window . sessionsModule . loadSession ( sessionId ) ;
setTimeout ( ( ) => _openPanel ( ) , 300 ) ;
} catch ( e ) {
console . error ( 'Failed to create document:' , e ) ;
if ( uiModule ) uiModule . showError ( 'Failed to create document' ) ;
}
} ) ;
}
// Archived toggle — flip the Documents list between active and archived.
const archivedBtn = document . getElementById ( 'doclib-archived-btn' ) ;
if ( archivedBtn ) archivedBtn . addEventListener ( 'click' , ( ) => {
_libraryArchivedView = ! _libraryArchivedView ;
archivedBtn . classList . toggle ( 'active' , _libraryArchivedView ) ;
archivedBtn . title = _libraryArchivedView ? 'Show active documents' : 'Show archived documents' ;
if ( _librarySelectMode ) libraryExitSelectMode ( ) ;
libraryFetch ( false ) ;
} ) ;
// Tidy button — remove empty/broken documents
const tidyBtn = document . getElementById ( 'doclib-tidy-btn' ) ;
if ( tidyBtn ) tidyBtn . addEventListener ( 'click' , async ( ) => {
tidyBtn . disabled = true ;
tidyBtn . classList . add ( 'spinning' ) ;
const origHTML = tidyBtn . innerHTML ;
tidyBtn . textContent = '' ;
const spinner = spinnerModule . create ( '' , 'clean' , 'whirlpool' ) ;
const _spEl = spinner . createElement ( ) ;
// Optical alignment: whirlpool reads 1px high inside the button.
_spEl . style . position = 'relative' ;
_spEl . style . top = '1px' ;
tidyBtn . appendChild ( _spEl ) ;
spinner . start ( ) ;
let totalDeleted = 0 ;
let totalFixed = 0 ;
let aiMessage = '' ;
try {
// Phase 1: regex tidy (empty/broken docs)
const [ res1 ] = await Promise . all ( [
fetch ( ` ${ API _BASE } /api/documents/tidy ` , { method : 'POST' } ) ,
new Promise ( r => setTimeout ( r , 600 ) ) ,
] ) ;
if ( res1 . ok ) {
const d1 = await res1 . json ( ) ;
totalDeleted += d1 . deleted || 0 ;
totalFixed += d1 . fixed _titles || 0 ;
}
// Phase 2: AI tidy (junk/test detection)
try {
const res2 = await fetch ( ` ${ API _BASE } /api/documents/ai-tidy ` , { method : 'POST' } ) ;
if ( res2 . ok ) {
const d2 = await res2 . json ( ) ;
totalDeleted += d2 . deleted || 0 ;
if ( d2 . message ) aiMessage = d2 . message ;
}
} catch ( _ ) { /* AI tidy is optional */ }
spinner . destroy ( ) ;
if ( totalDeleted === 0 && totalFixed === 0 ) {
tidyBtn . innerHTML = '<span style="opacity:0.7">Already tidy</span>' ;
} else {
const msg = aiMessage || ` Removed ${ totalDeleted } document ${ totalDeleted !== 1 ? 's' : '' } ` ;
if ( uiModule ) uiModule . showToast ( msg ) ;
libraryFetch ( false ) ;
}
setTimeout ( ( ) => { tidyBtn . innerHTML = origHTML ; tidyBtn . disabled = false ; tidyBtn . classList . remove ( 'spinning' ) ; } , 1500 ) ;
} catch ( e ) {
spinner . destroy ( ) ;
console . error ( 'Document tidy failed:' , e ) ;
if ( uiModule ) uiModule . showToast ( 'Tidy failed' ) ;
tidyBtn . disabled = false ;
tidyBtn . classList . remove ( 'spinning' ) ;
tidyBtn . innerHTML = origHTML ;
}
} ) ;
// Select mode
const selectBtn = document . getElementById ( 'doclib-select-btn' ) ;
if ( selectBtn ) selectBtn . addEventListener ( 'click' , ( ) => {
if ( _librarySelectMode ) libraryExitSelectMode ( ) ;
else libraryEnterSelectMode ( ) ;
} ) ;
const selectAll = document . getElementById ( 'doclib-select-all' ) ;
if ( selectAll ) selectAll . addEventListener ( 'change' , libraryToggleSelectAll ) ;
// Click anywhere in the bulk bar "All" label or count area to toggle select-all
const bulkCheckLabel = modal . querySelector ( '.memory-bulk-check-all' ) ;
if ( bulkCheckLabel ) {
bulkCheckLabel . addEventListener ( 'click' , ( e ) => {
if ( e . target === selectAll ) return ; // let native checkbox handle it
e . preventDefault ( ) ;
selectAll . checked = ! selectAll . checked ;
libraryToggleSelectAll ( ) ;
} ) ;
}
const selectedCountEl = document . getElementById ( 'doclib-selected-count' ) ;
if ( selectedCountEl ) {
selectedCountEl . style . cursor = 'pointer' ;
selectedCountEl . addEventListener ( 'click' , ( ) => {
selectAll . checked = ! selectAll . checked ;
libraryToggleSelectAll ( ) ;
} ) ;
}
const bulkActionsBtn = document . getElementById ( 'doclib-bulk-actions' ) ;
if ( bulkActionsBtn ) bulkActionsBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
if ( _librarySelectedIds . size === 0 ) {
if ( uiModule ) uiModule . showToast ( 'Select documents first' ) ;
return ;
}
_showLibDropdown ( e . currentTarget , [
{ label : _libraryArchivedView ? 'Restore' : 'Archive' , icon : _libraryArchivedView ? 'restore' : 'archive' , action : libraryBulkArchive } ,
{ label : 'Clone' , icon : 'clone' , action : libraryBulkClone } ,
{ label : 'Export' , icon : 'open' , action : libraryBulkExport } ,
{ label : 'Delete' , icon : 'delete' , danger : true , action : libraryBulkDelete } ,
] , { onCancel : libraryExitSelectMode } ) ;
} ) ;
const bulkCancelBtn = document . getElementById ( 'doclib-bulk-cancel' ) ;
if ( bulkCancelBtn ) bulkCancelBtn . addEventListener ( 'click' , libraryExitSelectMode ) ;
// Close on click outside modal content
modal . addEventListener ( 'click' , ( e ) => {
if ( uiModule . isTouchInsideModal ( ) ) return ;
if ( e . target === modal ) closeLibrary ( ) ;
} ) ;
// Escape key
_libraryEscHandler = ( e ) => {
if ( e . key === 'Escape' ) {
// Collapse expanded card first, then close modal on second Escape
const expanded = document . querySelector ( '#doclib-grid .doclib-card-expanded' ) ;
if ( expanded ) {
_collapseExpandedCard ( expanded ) ;
} else {
closeLibrary ( ) ;
}
}
} ;
document . addEventListener ( 'keydown' , _libraryEscHandler ) ;
// Toggle active on tool button
const btn = document . getElementById ( 'tool-doclib-btn' ) ;
if ( btn ) btn . classList . add ( 'active' ) ;
libraryFetch ( false ) ;
if ( window . innerWidth >= 768 ) searchInput . focus ( ) ;
}
export function closeLibrary ( ) {
if ( ! _libraryOpen ) return ;
_libraryOpen = false ;
_librarySelectMode = false ;
_librarySelectedIds . clear ( ) ;
_libraryImportMode = false ;
clearTimeout ( _librarySearchDebounce ) ;
const modal = document . getElementById ( 'doclib-modal' ) ;
if ( modal ) {
const content = modal . querySelector ( '.modal-content, .doclib-modal-content' ) ;
if ( content ) {
content . classList . add ( 'modal-closing' ) ;
content . addEventListener ( 'animationend' , ( ) => modal . remove ( ) , { once : true } ) ;
setTimeout ( ( ) => { if ( modal . parentElement ) modal . remove ( ) ; } , 250 ) ;
} else {
modal . remove ( ) ;
}
}
if ( _libraryEscHandler ) {
document . removeEventListener ( 'keydown' , _libraryEscHandler ) ;
_libraryEscHandler = null ;
}
const btn = document . getElementById ( 'tool-doclib-btn' ) ;
if ( btn ) btn . classList . remove ( 'active' ) ;
}
export function isLibraryOpen ( ) {
return _libraryOpen ;
}