2026-05-31 23:58:26 +09:00
// Session Management Functions
// This module handles all session-related operations
import Storage from './storage.js' ;
import uiModule , { styledPrompt } from './ui.js' ;
import markdownModule from './markdown.js' ;
import chatRenderer from './chatRenderer.js' ;
import { providerLogo } from './providers.js' ;
import { initModelPicker , updateModelPicker } from './modelPicker.js' ;
import themeModule from './theme.js' ;
import spinnerModule from './spinner.js' ;
const API _BASE = window . location . origin ;
let sessions = [ ] ;
let currentSessionId = null ;
let _sessionNavToken = 0 ;
let _skipAutoSelect = false ;
const SIDEBAR _MAX _VISIBLE = 10 ;
const FOLDER _MAX _VISIBLE = 5 ;
let _showAllSessions = false ;
let _expandedFolders = { } ; // folderName -> true if "show more" clicked
let _sortMode = Storage . get ( 'odysseus-session-sort' ) || 'active' ; // default to last active
let _autoCreateInProgress = false ; // guard against recursive auto-create
const _INCOGNITO _SESSIONS _KEY = 'ody-incognito-sessions' ; // sessionStorage key for incognito session IDs
const _isMac = /Mac|iPhone|iPad/ . test ( navigator . platform ) ;
const _mod = _isMac ? '⌘' : 'Ctrl' ;
function _getIncognitoIds ( ) {
try { return JSON . parse ( sessionStorage . getItem ( _INCOGNITO _SESSIONS _KEY ) || '[]' ) ; } catch { return [ ] ; }
}
function _markIncognito ( sid ) {
const ids = _getIncognitoIds ( ) ;
if ( ! ids . includes ( sid ) ) { ids . push ( sid ) ; sessionStorage . setItem ( _INCOGNITO _SESSIONS _KEY , JSON . stringify ( ids ) ) ; }
}
function _isIncognitoSession ( sid ) { return _getIncognitoIds ( ) . includes ( sid ) ; }
async function _cleanupIncognitoSessions ( ) {
const ids = _getIncognitoIds ( ) ;
if ( ids . length === 0 ) return ;
// Keep the current active incognito session alive, delete the rest
const toDelete = ids . filter ( sid => sid !== currentSessionId ) ;
if ( toDelete . length === 0 ) return ;
const keep = ids . filter ( sid => sid === currentSessionId ) ;
sessionStorage . setItem ( _INCOGNITO _SESSIONS _KEY , JSON . stringify ( keep ) ) ;
await Promise . all ( toDelete . map ( sid =>
fetch ( ` ${ API _BASE } /api/session/ ${ sid } ` , { method : 'DELETE' } ) . catch ( ( ) => { } )
) ) ;
}
// Research indicator tracking
const _researchingSessions = new Set ( ) ;
const _streamingSessions = new Set ( ) ; // Background chat streams (not polled against research API)
const _completedSessions = new Set ( ) ; // Sessions with completed background streams
let _researchPollTimer = null ;
// Session list keyboard navigation state
let _sessionListFocused = false ;
/** Clear current session from UI (after delete/archive). */
function _deselectCurrentSession ( sid ) {
if ( currentSessionId !== sid ) return ;
currentSessionId = null ;
uiModule . el ( 'chat-history' ) . innerHTML = '' ;
uiModule . el ( 'current-meta' ) . textContent = 'Odysseus Chat' ;
Storage . remove ( 'lastSessionId' ) ;
history . replaceState ( null , '' , window . location . pathname ) ;
if ( window . chatModule && window . chatModule . showWelcomeScreen ) {
window . chatModule . showWelcomeScreen ( ) ;
}
// Reset send button to idle state
const submitBtn = document . querySelector ( '.send-btn' ) ;
if ( submitBtn ) {
submitBtn . dataset . mode = '' ;
delete submitBtn . dataset . phase ;
submitBtn . classList . remove ( 'recording' ) ;
}
if ( window . _updateSendBtnIcon ) window . _updateSendBtnIcon ( ) ;
}
2026-06-02 09:52:22 -05:00
function _removeSessionFromLocalState ( sid ) {
if ( ! sid ) return ;
const id = String ( sid ) ;
sessions = sessions . filter ( s => String ( s . id ) !== id ) ;
_selectedIds . delete ( id ) ;
try {
const savedOrder = Storage . get ( 'session-order' ) ;
if ( savedOrder ) {
const orderIds = JSON . parse ( savedOrder ) ;
if ( Array . isArray ( orderIds ) && orderIds . some ( x => String ( x ) === id ) ) {
Storage . set ( 'session-order' , JSON . stringify ( orderIds . filter ( x => String ( x ) !== id ) ) ) ;
}
}
} catch ( e ) {
console . warn ( 'Failed to prune deleted session order:' , e ) ;
}
document . querySelectorAll ( '.list-item[data-session-id]' ) . forEach ( el => {
if ( String ( el . dataset . sessionId ) === id ) el . remove ( ) ;
} ) ;
_deselectCurrentSession ( id ) ;
}
function _normalizeSessionsList ( fetched ) {
if ( ! Array . isArray ( fetched ) ) return [ ] ;
const seen = new Set ( ) ;
const unique = [ ] ;
for ( const session of fetched ) {
if ( ! session || session . id == null ) continue ;
const id = String ( session . id ) ;
if ( seen . has ( id ) ) continue ;
seen . add ( id ) ;
unique . push ( session ) ;
}
return unique ;
}
2026-05-31 23:58:26 +09:00
// Initialize dependencies from app.js (no-op: dependencies now imported directly)
export function initDependencies ( ) { }
// ── Folder state persistence ──
const FOLDER _STATE _KEY = 'odysseus-folder-state' ;
const FOLDER _ORDER _KEY = 'odysseus-folder-order' ;
function loadFolderState ( ) {
return Storage . getJSON ( FOLDER _STATE _KEY , { } ) ;
}
function saveFolderState ( state ) {
Storage . setJSON ( FOLDER _STATE _KEY , state ) ;
}
function loadFolderOrder ( ) {
return Storage . getJSON ( FOLDER _ORDER _KEY , [ ] ) ;
}
function saveFolderOrder ( order ) {
Storage . setJSON ( FOLDER _ORDER _KEY , order ) ;
}
/** Get all unique folder names from current sessions. */
function getFolderNames ( ) {
const names = new Set ( ) ;
sessions . forEach ( s => { if ( s . folder ) names . add ( s . folder ) ; } ) ;
return Array . from ( names ) . sort ( ) ;
}
/** Move a session to a folder via the API. */
async function moveToFolder ( sessionId , folderName ) {
const fd = new FormData ( ) ;
fd . append ( 'folder' , folderName || '' ) ;
await fetch ( ` ${ API _BASE } /api/session/ ${ sessionId } ` , { method : 'PATCH' , body : fd } ) ;
// Update local data
const s = sessions . find ( x => x . id === sessionId ) ;
if ( s ) s . folder = folderName || null ;
renderSessionList ( ) ;
}
/** Build the "Move to folder" submenu for a session dropdown. */
function buildFolderSubmenu ( sessionId , currentFolder , dropdown ) {
const folders = getFolderNames ( ) ;
const moveItem = document . createElement ( 'div' ) ;
moveItem . className = 'dropdown-item-compact' ;
moveItem . style . position = 'relative' ;
const _folderIcon = '<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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>' ;
moveItem . innerHTML = '<span class="dropdown-icon">' + _folderIcon + '</span><span>Move to folder</span>' ;
const sub = document . createElement ( 'div' ) ;
sub . className = 'dropdown session-folder-submenu' ;
// "No folder" option
const noneOpt = document . createElement ( 'div' ) ;
noneOpt . className = 'dropdown-item-compact' ;
if ( ! currentFolder ) noneOpt . style . opacity = '0.5' ;
noneOpt . textContent = '(No folder)' ;
noneOpt . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
await moveToFolder ( sessionId , '' ) ;
dropdown . style . display = 'none' ;
sub . style . display = 'none' ;
} ) ;
sub . appendChild ( noneOpt ) ;
// Existing folders
folders . forEach ( f => {
const opt = document . createElement ( 'div' ) ;
opt . className = 'dropdown-item-compact' ;
if ( f === currentFolder ) opt . style . opacity = '0.5' ;
opt . textContent = f ;
opt . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
await moveToFolder ( sessionId , f ) ;
// Auto-flip to By Folder view so the user can see where the
// chat went, same as when creating a new folder.
setSortMode ( 'group' ) ;
dropdown . style . display = 'none' ;
sub . style . display = 'none' ;
} ) ;
sub . appendChild ( opt ) ;
} ) ;
// "New folder" option
const newOpt = document . createElement ( 'div' ) ;
newOpt . className = 'dropdown-item-compact' ;
newOpt . style . color = 'var(--accent-primary)' ;
newOpt . textContent = '+ New Folder' ;
newOpt . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
const name = await styledPrompt ( 'Name this folder:' , {
title : 'New folder' ,
placeholder : 'e.g. Work, Research, Drafts' ,
confirmText : 'Create' ,
} ) ;
if ( ! name || ! name . trim ( ) ) return ;
await moveToFolder ( sessionId , name . trim ( ) ) ;
// Auto-flip to By Folder view so the user immediately sees the
// folder they just created — otherwise the new folder disappears
// into the flat list and looks like the action did nothing.
setSortMode ( 'group' ) ;
dropdown . style . display = 'none' ;
sub . style . display = 'none' ;
} ) ;
sub . appendChild ( newOpt ) ;
moveItem . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
if ( sub . style . display === 'block' ) {
sub . style . display = 'none' ;
} else {
const rect = moveItem . getBoundingClientRect ( ) ;
const isMobile = window . innerWidth <= 768 ;
sub . style . top = '-9999px' ;
sub . style . display = 'block' ;
const subRect = sub . getBoundingClientRect ( ) ;
if ( isMobile ) {
// On mobile: position below the dropdown, centered
const ddRect = dropdown . getBoundingClientRect ( ) ;
sub . style . left = Math . max ( 8 , ddRect . left ) + 'px' ;
sub . style . width = Math . min ( ddRect . width , window . innerWidth - 16 ) + 'px' ;
const topBelow = ddRect . bottom + 4 ;
if ( topBelow + subRect . height > window . innerHeight ) {
sub . style . top = Math . max ( 8 , ddRect . top - subRect . height - 4 ) + 'px' ;
} else {
sub . style . top = topBelow + 'px' ;
}
} else {
// Desktop: to the right
sub . style . left = rect . right + 2 + 'px' ;
sub . style . width = '' ;
if ( rect . top + subRect . height > window . innerHeight ) {
sub . style . top = Math . max ( 2 , window . innerHeight - subRect . height - 4 ) + 'px' ;
} else {
sub . style . top = rect . top + 'px' ;
}
// Clamp right edge
if ( rect . right + 2 + subRect . width > window . innerWidth - 8 ) {
sub . style . left = Math . max ( 8 , rect . left - subRect . width - 2 ) + 'px' ;
}
}
}
} ) ;
sub . addEventListener ( 'click' , ( e ) => e . stopPropagation ( ) ) ;
document . addEventListener ( 'click' , ( ) => { sub . style . display = 'none' ; } ) ;
document . body . appendChild ( sub ) ;
return moveItem ;
}
/** Create a single session list-item element. */
function createSessionItem ( s ) {
const div = document . createElement ( 'div' ) ;
div . className = 'list-item session-item' ;
div . setAttribute ( 'role' , 'option' ) ;
div . setAttribute ( 'tabindex' , '-1' ) ;
div . setAttribute ( 'data-session-id' , s . id ) ;
// Special-session sentinel — true for the legacy OpenClaw row, which
// skips the normal provider dot / name / action chrome. Was
// previously detected here but the declaration got removed while
// leaving the references in place, causing ReferenceError on every
// session list re-render.
const isOpenClaw = s . is _openclaw || s . id === 'openclaw' ;
// Drag handle
const handle = document . createElement ( 'span' ) ;
handle . className = 'item-drag-handle' ;
handle . textContent = '\u22EE\u22EE' ;
handle . title = 'Drag to reorder' ;
div . appendChild ( handle ) ;
// Provider dot indicator
if ( ! isOpenClaw ) {
const star = document . createElement ( 'span' ) ;
const _logo = providerLogo ( s . model ) ;
if ( _logo ) {
star . className = 'session-star provider-logo' ;
star . innerHTML = _logo ;
star . style . opacity = '0.4' ;
} else {
star . className = 'session-star' ;
}
div . appendChild ( star ) ;
}
// Session type icon
const icon = document . createElement ( 'span' ) ;
const _isFork = s . name && ( s . name . startsWith ( 'Fork:' ) || s . name . startsWith ( '\u2ADD' ) ) ;
const _isGroup = s . name && s . name . startsWith ( '[GRP]' ) ;
icon . className = 'session-icon' + ( s . has _documents ? ' has-docs' : '' ) ;
if ( _isGroup ) {
icon . innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' ;
} else if ( _isFork ) {
icon . textContent = '\u2ADD' ;
icon . style . fontSize = '14px' ;
} else if ( s . has _documents ) {
icon . innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>' ;
} else if ( s . has _images ) {
icon . innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>' ;
} else if ( s . mode === 'agent' ) {
icon . 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="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>' ;
} else if ( s . mode === 'research' ) {
icon . innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>' ;
} else {
icon . innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>' ;
}
// Favorite bookmark replaces session-icon when important
if ( s . is _important && ! isOpenClaw ) {
icon . className = 'session-icon session-fav' ;
icon . title = 'Unfavorite' ;
icon . innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>' ;
icon . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
const fd = new FormData ( ) ;
fd . append ( 'important' , false ) ;
await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } /important ` , { method : 'POST' , body : fd } ) ;
s . is _important = false ;
uiModule . showToast ( 'Unfavorited' ) ;
renderSessionList ( ) ;
} ) ;
}
div . appendChild ( icon ) ;
const span = document . createElement ( 'span' ) ;
span . className = 'grow' ;
let chatTitle = s . name || '' ;
if ( _isFork ) chatTitle = chatTitle . replace ( /^Fork:\s*/ , '' ) . replace ( /^\u2ADD\s*/ , '' ) ;
if ( _isGroup ) chatTitle = chatTitle . replace ( /^\[GRP\]\s*/ , '' ) ;
let label = chatTitle ;
if ( s . model ) label += ' · ' + s . model . split ( '/' ) . pop ( ) ;
if ( s . archived ) label += ' [archived]' ;
span . textContent = label ;
span . title = ( s . model ? s . model . split ( '/' ) . pop ( ) + ' · ' : '' ) + chatTitle ;
span . classList . add ( 'text-ellipsis' ) ;
// Double-click to rename (only when session is already selected)
if ( ! isOpenClaw ) {
span . addEventListener ( 'dblclick' , ( e ) => {
if ( currentSessionId !== s . id ) return ; // must be selected first
e . stopPropagation ( ) ;
const input = document . createElement ( 'input' ) ;
input . type = 'text' ;
input . value = s . name || '' ;
input . className = 'session-rename-input' ;
span . replaceWith ( input ) ;
input . focus ( ) ;
input . select ( ) ;
const _stopGuard = _guardSidebarDuringRename ( ) ;
const commit = async ( ) => {
const newName = input . value . trim ( ) ;
if ( newName && newName !== s . name ) {
const fd = new FormData ( ) ;
fd . append ( 'name' , newName ) ;
await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } ` , { method : 'PATCH' , body : fd } ) ;
s . name = newName ;
uiModule . showToast ( 'Renamed' ) ;
}
_forceSidebarOpen ( ) ;
renderSessionList ( ) ;
_stopGuard ( ) ;
} ;
input . addEventListener ( 'blur' , commit ) ;
input . addEventListener ( 'keydown' , ( ev ) => {
if ( ev . key === 'Enter' ) { ev . preventDefault ( ) ; input . blur ( ) ; }
if ( ev . key === 'Escape' ) { input . removeEventListener ( 'blur' , commit ) ; _forceSidebarOpen ( ) ; renderSessionList ( ) ; _stopGuard ( ) ; }
} ) ;
} ) ;
}
// Clicking anywhere on the row selects the session (except drag handle and menu)
// On mobile, suppress click if user was scrolling (touchmove detected)
// Long press on mobile shows context menu
let _touchMoved = false ;
let _longPressTimer = null ;
let _longPressed = false ;
div . addEventListener ( 'touchstart' , ( e ) => {
_touchMoved = false ;
_longPressed = false ;
if ( window . innerWidth > 768 ) return ;
_longPressTimer = setTimeout ( ( ) => {
_longPressed = true ;
// Haptic feedback if available
if ( navigator . vibrate ) navigator . vibrate ( 30 ) ;
// Show the session dropdown directly (menu button is hidden on mobile)
const dd = div . _sessionDropdown ;
if ( dd ) {
// Close any other open dropdowns
document . querySelectorAll ( '.dropdown' ) . forEach ( d => { if ( d !== dd ) d . style . display = 'none' ; } ) ;
const rect = div . getBoundingClientRect ( ) ;
dd . style . position = 'fixed' ;
dd . style . left = rect . left + 'px' ;
dd . style . top = ( rect . bottom + 4 ) + 'px' ;
dd . style . right = 'auto' ;
dd . style . display = 'block' ;
dd . style . zIndex = '1000' ;
// Clamp to viewport
requestAnimationFrame ( ( ) => {
const mr = dd . getBoundingClientRect ( ) ;
if ( mr . bottom > window . innerHeight - 8 ) dd . style . top = ( rect . top - mr . height - 4 ) + 'px' ;
if ( mr . right > window . innerWidth - 8 ) { dd . style . left = 'auto' ; dd . style . right = '8px' ; }
} ) ;
// Close on tap outside
const close = ( ev ) => { if ( ! dd . contains ( ev . target ) ) { dd . style . display = 'none' ; document . removeEventListener ( 'click' , close , true ) ; } } ;
setTimeout ( ( ) => document . addEventListener ( 'click' , close , true ) , 100 ) ;
}
} , 500 ) ;
} , { passive : true } ) ;
div . addEventListener ( 'touchmove' , ( ) => {
_touchMoved = true ;
if ( _longPressTimer ) { clearTimeout ( _longPressTimer ) ; _longPressTimer = null ; }
} , { passive : true } ) ;
div . addEventListener ( 'touchend' , ( ) => {
if ( _longPressTimer ) { clearTimeout ( _longPressTimer ) ; _longPressTimer = null ; }
} , { passive : true } ) ;
div . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.item-drag-handle' ) || e . target . closest ( '.session-fav' ) || e . target . closest ( '.hamburger' ) || e . target . closest ( '.session-dropdown' ) || e . target . closest ( '.session-rename-input' ) || e . target . closest ( '.session-select-cb' ) ) return ;
if ( _touchMoved || _longPressed ) { _touchMoved = false ; _longPressed = false ; return ; }
// In select mode, toggle dot instead of navigating
if ( _selectMode ) {
const dot = div . querySelector ( '.session-select-cb' ) ;
if ( dot ) dot . click ( ) ;
return ;
}
selectSession ( s . id ) ;
} ) ;
// Create a dropdown menu button
const menuBtn = document . createElement ( 'button' ) ;
menuBtn . innerHTML = '<svg width="18" height="18" 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>' ;
menuBtn . title = 'Session actions' ;
menuBtn . className = 'hamburger session-menu-btn' ;
// Create dropdown menu
const dropdown = document . createElement ( 'div' ) ;
dropdown . className = 'dropdown session-dropdown session-dropdown-menu' ;
// Create menu items
const _icon = ( svg ) => ` <span class="dropdown-icon"> ${ svg } </span> ` ;
const _renameIcon = '<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="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>' ;
const _archiveIcon = '<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="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>' ;
const _deleteIcon = '<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 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>' ;
const _copyIcon = '<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 renameItem = document . createElement ( 'div' ) ;
renameItem . className = 'dropdown-item-compact' ;
renameItem . innerHTML = _icon ( _renameIcon ) + '<span>Rename</span>' ;
const archiveItem = document . createElement ( 'div' ) ;
archiveItem . className = 'dropdown-item-compact' ;
archiveItem . innerHTML = _icon ( _archiveIcon ) + '<span>Archive</span>' ;
const deleteItem = document . createElement ( 'div' ) ;
deleteItem . className = 'dropdown-item-compact dropdown-item-danger' ;
deleteItem . innerHTML = _icon ( _deleteIcon ) + '<span>Delete</span><span class="dropdown-shortcut">' + _mod + '+Alt+D</span>' ;
dropdown . appendChild ( renameItem ) ;
// Star/Unstar item
if ( ! isOpenClaw ) {
const _favIcon = s . is _important
? '<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="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>'
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>' ;
const starItem = document . createElement ( 'div' ) ;
starItem . className = 'dropdown-item-compact' ;
starItem . innerHTML = _icon ( _favIcon ) + '<span>' + ( s . is _important ? 'Unfavorite' : 'Favorite' ) + '</span><span class="dropdown-shortcut">' + _mod + '+Alt+F</span>' ;
starItem . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
const newVal = ! s . is _important ;
const fd = new FormData ( ) ;
fd . append ( 'important' , newVal ) ;
await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } /important ` , { method : 'POST' , body : fd } ) ;
s . is _important = newVal ;
dropdown . style . display = 'none' ;
renderSessionList ( ) ;
} ) ;
dropdown . appendChild ( starItem ) ;
}
const copyItem = document . createElement ( 'div' ) ;
copyItem . className = 'dropdown-item-compact' ;
copyItem . innerHTML = _icon ( _copyIcon ) + '<span>Copy Chat</span>' ;
copyItem . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
dropdown . style . display = 'none' ;
try {
const res = await fetch ( ` ${ API _BASE } /api/history/ ${ s . id } ` ) ;
const data = await res . json ( ) ;
const msgs = data . history || [ ] ;
if ( ! msgs . length ) { uiModule . showToast ( 'No messages to copy' ) ; return ; }
const lines = msgs
. filter ( m => m . role === 'user' || m . role === 'assistant' )
. map ( m => {
const label = m . role === 'user' ? 'You' : 'AI' ;
const text = typeof m . content === 'string' ? m . content . trim ( ) : JSON . stringify ( m . content ) ;
return ` ${ label } : ${ text } ` ;
} ) ;
const text = lines . join ( '\n\n' ) ;
try {
await navigator . clipboard . writeText ( text ) ;
} catch ( _clipErr ) {
// Fallback for non-secure contexts
const ta = document . createElement ( 'textarea' ) ;
ta . value = text ;
ta . style . cssText = 'position:fixed;left:-9999px' ;
document . body . appendChild ( ta ) ;
ta . select ( ) ;
document . execCommand ( 'copy' ) ;
ta . remove ( ) ;
}
uiModule . showToast ( 'Chat copied to clipboard' ) ;
} catch ( e ) {
console . error ( 'Copy chat failed:' , e ) ;
uiModule . showError ( 'Failed to copy chat' ) ;
}
} ) ;
// Rename is already appended above (line 393)
// "Select" — enter bulk select mode with this session pre-selected
if ( ! isOpenClaw ) {
const selectMoreItem = document . createElement ( 'div' ) ;
selectMoreItem . className = 'dropdown-item-compact' ;
selectMoreItem . innerHTML = _icon ( '<span style="font-size:16px;line-height:1;">●</span>' ) + '<span>Select</span>' ;
selectMoreItem . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
dropdown . style . display = 'none' ;
_enterSelectMode ( ) ;
const dot = div . querySelector ( '.session-select-cb' ) ;
if ( dot ) { dot . _checked = true ; dot . innerHTML = '●' ; dot . style . opacity = '1' ; dot . style . color = 'var(--accent, var(--red))' ; _selectedIds . add ( s . id ) ; _updateBulkCount ( ) ; }
} ) ;
// On mobile, "Select" is the primary multi-pick action — put it at the top
// of the menu. On desktop keep its original position.
if ( window . innerWidth <= 768 ) {
dropdown . insertBefore ( selectMoreItem , dropdown . firstChild ) ;
} else {
dropdown . appendChild ( selectMoreItem ) ;
}
}
// Copy & Move to folder
const folderItem = buildFolderSubmenu ( s . id , s . folder , dropdown ) ;
dropdown . appendChild ( copyItem ) ;
dropdown . appendChild ( folderItem ) ;
// Separator before destructive actions
const _sep = document . createElement ( 'div' ) ;
_sep . style . cssText = 'height:1px;margin:3px 0;background:color-mix(in srgb,var(--border) 40%,transparent)' ;
dropdown . appendChild ( _sep ) ;
dropdown . appendChild ( archiveItem ) ;
dropdown . appendChild ( deleteItem ) ;
// Mobile-only Cancel — explicit close for touch users. CSS hides it on
// desktop (outside-click already dismisses cleanly there).
const _cancelIcon = '<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>' ;
const cancelItem = document . createElement ( 'div' ) ;
cancelItem . className = 'dropdown-item-compact dropdown-cancel-mobile' ;
cancelItem . innerHTML = _icon ( _cancelIcon ) + '<span>Cancel</span>' ;
cancelItem . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
dropdown . style . display = 'none' ;
} ) ;
dropdown . appendChild ( cancelItem ) ;
// Add event listeners
menuBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
// Close any other open dropdowns
document . querySelectorAll ( '.dropdown' ) . forEach ( d => {
if ( d !== dropdown ) d . style . display = 'none' ;
} ) ;
// Toggle this dropdown
if ( dropdown . style . display === 'block' ) {
dropdown . style . display = 'none' ;
} else {
// Position the dropdown using viewport coords
const rect = menuBtn . getBoundingClientRect ( ) ;
dropdown . style . left = '' ;
dropdown . style . right = ( window . innerWidth - rect . right ) + 'px' ;
// Show off-screen first to measure height
dropdown . style . top = '-9999px' ;
dropdown . style . display = 'block' ;
const ddRect = dropdown . getBoundingClientRect ( ) ;
// Flip above if not enough room below
if ( rect . bottom + 2 + ddRect . height > window . innerHeight ) {
dropdown . style . top = Math . max ( 2 , rect . top - ddRect . height - 2 ) + 'px' ;
} else {
dropdown . style . top = rect . bottom + 2 + 'px' ;
}
}
} ) ;
renameItem . addEventListener ( 'click' , ( ) => {
dropdown . style . display = 'none' ;
_forceSidebarOpen ( ) ;
// Find the session row's name span and start inline editing
const sessionEl = document . querySelector ( ` .list-item[data-session-id=" ${ s . id } "] ` ) ;
if ( ! sessionEl ) return ;
const span = sessionEl . querySelector ( '.grow' ) ;
if ( ! span || sessionEl . querySelector ( '.session-rename-input' ) ) return ;
const input = document . createElement ( 'input' ) ;
input . type = 'text' ;
input . value = s . name || '' ;
input . className = 'session-rename-input' ;
span . replaceWith ( input ) ;
input . focus ( ) ;
input . select ( ) ;
const _stopGuard = _guardSidebarDuringRename ( ) ;
const commit = async ( ) => {
const newName = input . value . trim ( ) ;
if ( newName && newName !== s . name ) {
const fd = new FormData ( ) ;
fd . append ( 'name' , newName ) ;
await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } ` , { method : 'PATCH' , body : fd } ) ;
s . name = newName ;
uiModule . showToast ( 'Renamed' ) ;
}
_forceSidebarOpen ( ) ;
renderSessionList ( ) ;
_stopGuard ( ) ;
} ;
input . addEventListener ( 'blur' , commit ) ;
input . addEventListener ( 'keydown' , ( ev ) => {
if ( ev . key === 'Enter' ) { ev . preventDefault ( ) ; input . blur ( ) ; }
if ( ev . key === 'Escape' ) { input . removeEventListener ( 'blur' , commit ) ; _forceSidebarOpen ( ) ; renderSessionList ( ) ; _stopGuard ( ) ; }
} ) ;
} ) ;
deleteItem . addEventListener ( 'click' , async ( ) => {
if ( s . is _important ) {
uiModule . showToast ( 'Unfavorite before deleting' ) ;
dropdown . style . display = 'none' ;
return ;
}
dropdown . style . display = 'none' ;
2026-06-02 07:43:34 -04:00
if ( ! await uiModule . styledConfirm ( 'Delete this session?' , { confirmText : 'Delete' , danger : true } ) ) {
_forceSidebarOpen ( ) ;
return ;
}
2026-05-31 23:58:26 +09:00
const wasCurrentSession = currentSessionId === s . id ;
// If streaming, abort it before deleting
if ( wasCurrentSession && window . chatModule && window . chatModule . abortCurrentRequest ) {
window . chatModule . abortCurrentRequest ( ) ;
}
_deselectCurrentSession ( s . id ) ;
2026-06-02 09:52:22 -05:00
_removeSessionFromLocalState ( s . id ) ;
2026-05-31 23:58:26 +09:00
_skipAutoSelect = true ;
// Clean up persistent chat mapping
try {
const pm = await import ( './presets.js' ) ;
if ( pm . removePersistentChat ) pm . removePersistentChat ( s . id ) ;
} catch ( e ) { }
// On mobile, close sidebar if we deleted the active session so user sees welcome screen
if ( wasCurrentSession && window . innerWidth <= 768 ) {
const sidebar = document . getElementById ( 'sidebar' ) ;
if ( sidebar ) sidebar . classList . add ( 'hidden' ) ;
const backdrop = document . getElementById ( 'sidebar-backdrop' ) ;
if ( backdrop ) backdrop . classList . remove ( 'visible' ) ;
} else {
_forceSidebarOpen ( ) ;
}
2026-06-02 21:37:29 -07:00
// Await API deletion, then reload the authoritative list from the server
try {
await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } ` , { method : 'DELETE' } ) ;
} catch ( e ) { /* network error — session may still exist server-side */ }
await loadSessions ( ) ;
2026-05-31 23:58:26 +09:00
} ) ;
archiveItem . addEventListener ( 'click' , async ( ) => {
dropdown . style . display = 'none' ;
_forceSidebarOpen ( ) ;
try {
const response = await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } /archive ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' }
} ) ;
if ( response . ok ) {
_forceSidebarOpen ( ) ;
await loadSessions ( ) ;
dropdown . style . display = 'none' ;
uiModule . showToast ( 'Session archived' ) ;
} else {
throw new Error ( 'Failed to archive session' ) ;
}
} catch ( error ) {
console . error ( 'Error archiving session:' , error ) ;
uiModule . showError ( 'Failed to archive session' ) ;
}
} ) ;
// Dropdowns are closed by the shared global listener (_initDropdownDismiss)
// Prevent dropdown from closing when clicking inside it
dropdown . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
} ) ;
div . appendChild ( span ) ;
// Apply processing/completed state to the star dot
var _isProcessing = _researchingSessions . has ( s . id ) || _streamingSessions . has ( s . id ) ;
var _isDone = _completedSessions . has ( s . id ) && ! _isProcessing ;
if ( ! isOpenClaw ) {
var _starEl = div . querySelector ( '.session-star' ) ;
if ( _starEl ) {
_starEl . dataset . sessionId = s . id ;
if ( _isProcessing ) {
_starEl . classList . add ( 'processing' ) ;
_starEl . style . opacity = '1' ;
} else if ( _isDone ) {
_starEl . classList . add ( 'notify' ) ;
_starEl . style . opacity = '1' ;
div . classList . add ( 'stream-complete' ) ;
}
}
}
div . appendChild ( menuBtn ) ;
dropdown . addEventListener ( 'click' , ( e ) => e . stopPropagation ( ) ) ;
document . body . appendChild ( dropdown ) ;
div . _sessionDropdown = dropdown ;
return div ;
}
let _renderRAF = null ;
export function renderSessionList ( ) {
// Debounce rapid re-renders within the same frame
if ( _renderRAF ) cancelAnimationFrame ( _renderRAF ) ;
_renderRAF = requestAnimationFrame ( _renderSessionListImpl ) ;
}
function _renderSessionListImpl ( ) {
_renderRAF = null ;
const list = uiModule . el ( 'session-list' ) ;
if ( ! list ) return ;
// Get saved order from localStorage
const savedOrder = Storage . get ( 'session-order' ) ;
let orderedSessions = sessions . filter ( s => ! s . archived && s . folder !== 'Assistant' && ! _isIncognitoSession ( s . id ) && ( s . name || '' ) . trim ( ) !== 'Nobody' && ( s . name || '' ) . trim ( ) !== 'Incognito' ) ;
if ( savedOrder ) {
try {
const orderIds = JSON . parse ( savedOrder ) ;
const sessionMap = new Map ( orderedSessions . map ( s => [ s . id , s ] ) ) ;
const ordered = [ ] ;
orderIds . forEach ( id => {
if ( sessionMap . has ( id ) ) {
ordered . push ( sessionMap . get ( id ) ) ;
sessionMap . delete ( id ) ;
}
} ) ;
// Append any new sessions not in saved order
sessionMap . forEach ( s => ordered . push ( s ) ) ;
orderedSessions = ordered ;
} catch ( e ) {
console . warn ( 'Failed to restore session order:' , e ) ;
}
}
// Clean up any previous session dropdowns and folder submenus from body
document . querySelectorAll ( '.session-dropdown, .folder-submenu' ) . forEach ( d => d . remove ( ) ) ;
const _frag = document . createDocumentFragment ( ) ;
// ── Flat sort modes: ignore folders, show one ordered list. ──
// Folders are only shown when _sortMode === 'group' (or null/empty
// for manual mode). This keeps the picker simple: a folder-grouped
// view is one of the sort choices, alongside Last Active / Newest.
if ( _sortMode && _sortMode !== 'group' ) {
orderedSessions . sort ( ( a , b ) => {
if ( _sortMode === 'newest' ) return ( b . created _at || '' ) . localeCompare ( a . created _at || '' ) ;
// "Last active" sorts by the last actual MESSAGE, not updated_at —
// updated_at is bumped by renames / model swaps / folder moves, which
// made the order feel random. Fall back to updated_at/created_at for
// older rows that predate the last_message_at backfill.
if ( _sortMode === 'active' ) {
const av = a . last _message _at || a . updated _at || a . created _at || '' ;
const bv = b . last _message _at || b . updated _at || b . created _at || '' ;
return bv . localeCompare ( av ) ;
}
return 0 ;
} ) ;
// Starred still float to top
const starred = orderedSessions . filter ( s => s . is _important ) ;
const rest = orderedSessions . filter ( s => ! s . is _important ) ;
const allFlat = [ ... starred , ... rest ] ;
const limit = _showAllSessions ? allFlat . length : SIDEBAR _MAX _VISIBLE ;
const visible = allFlat . slice ( 0 , limit ) ;
const activeIdx = allFlat . findIndex ( s => s . id === currentSessionId ) ;
if ( ! _showAllSessions && activeIdx >= limit ) visible . push ( allFlat [ activeIdx ] ) ;
visible . forEach ( s => _frag . appendChild ( createSessionItem ( s ) ) ) ;
if ( allFlat . length > SIDEBAR _MAX _VISIBLE ) {
const remaining = allFlat . length - SIDEBAR _MAX _VISIBLE ;
const toggleBtn = document . createElement ( 'button' ) ;
toggleBtn . className = 'session-show-more-btn' ;
toggleBtn . textContent = _showAllSessions ? 'Show less' : ` Show ${ remaining } more ` ;
toggleBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
_showAllSessions = ! _showAllSessions ;
renderSessionList ( ) ;
} ) ;
_frag . appendChild ( toggleBtn ) ;
}
list . innerHTML = '' ;
list . appendChild ( _frag ) ;
_postRenderSessionList ( list ) ;
return ;
}
// ── Group / manual mode: render folders, then unfiled sessions. ──
const folderState = loadFolderState ( ) ;
const folders = { } ; // folderName -> [sessions]
const unfiled = [ ] ;
orderedSessions . forEach ( s => {
if ( s . folder ) {
if ( ! folders [ s . folder ] ) folders [ s . folder ] = [ ] ;
folders [ s . folder ] . push ( s ) ;
} else {
unfiled . push ( s ) ;
}
} ) ;
// Move starred sessions to top of each group, preserving relative order
const starPartition = ( arr ) => {
const starred = arr . filter ( s => s . is _important ) ;
const rest = arr . filter ( s => ! s . is _important ) ;
arr . length = 0 ;
arr . push ( ... starred , ... rest ) ;
} ;
starPartition ( unfiled ) ;
Object . values ( folders ) . forEach ( arr => starPartition ( arr ) ) ;
// Render folders first (above unfiled sessions)
const savedFolderOrder = loadFolderOrder ( ) ;
const allFolderNames = Object . keys ( folders ) ;
const orderedFolderNames = [ ] ;
savedFolderOrder . forEach ( name => {
if ( allFolderNames . includes ( name ) ) orderedFolderNames . push ( name ) ;
} ) ;
allFolderNames . forEach ( name => {
if ( ! orderedFolderNames . includes ( name ) ) orderedFolderNames . push ( name ) ;
} ) ;
orderedFolderNames . forEach ( folderName => {
const folderDiv = document . createElement ( 'div' ) ;
folderDiv . className = 'session-folder' ;
folderDiv . dataset . folderName = folderName ;
const header = document . createElement ( 'div' ) ;
header . className = 'session-folder-header' ;
header . dataset . folderName = folderName ;
const collapsed = folderState [ folderName ] === false ;
// Drag handle for folder reordering
const dragHandle = document . createElement ( 'span' ) ;
dragHandle . className = 'folder-drag-handle' ;
dragHandle . textContent = '\u2630' ;
dragHandle . title = 'Drag to reorder folder' ;
header . appendChild ( dragHandle ) ;
const toggle = document . createElement ( 'span' ) ;
toggle . className = 'folder-toggle' ;
toggle . textContent = collapsed ? '\u25B6' : '\u25BC' ;
header . appendChild ( toggle ) ;
const nameSpan = document . createElement ( 'span' ) ;
nameSpan . className = 'folder-name' ;
nameSpan . textContent = folderName ;
header . appendChild ( nameSpan ) ;
const countSpan = document . createElement ( 'span' ) ;
countSpan . className = 'folder-count' ;
countSpan . textContent = ` ( ${ folders [ folderName ] . length } ) ` ;
header . appendChild ( countSpan ) ;
// Delete folder button
const deleteBtn = document . createElement ( 'button' ) ;
deleteBtn . className = 'folder-delete-btn' ;
deleteBtn . textContent = '\u00d7' ;
deleteBtn . title = 'Delete folder and all sessions' ;
deleteBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
const count = folders [ folderName ] . length ;
if ( ! await uiModule . styledConfirm ( ` Delete folder " ${ folderName } " and all ${ count } session(s) inside it? ` , { confirmText : 'Delete' , danger : true } ) ) return ;
for ( const s of folders [ folderName ] ) {
try {
await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } ` , { method : 'DELETE' } ) ;
_deselectCurrentSession ( s . id ) ;
} catch ( err ) {
console . error ( 'Failed to delete session:' , s . id , err ) ;
}
}
await loadSessions ( ) ;
} ) ;
header . appendChild ( deleteBtn ) ;
let _folderTouchMoved = false ;
header . addEventListener ( 'touchstart' , ( ) => { _folderTouchMoved = false ; } , { passive : true } ) ;
header . addEventListener ( 'touchmove' , ( ) => { _folderTouchMoved = true ; } , { passive : true } ) ;
header . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
if ( e . target . closest ( '.folder-drag-handle' ) || e . target . closest ( '.folder-delete-btn' ) ) return ;
if ( _folderTouchMoved ) { _folderTouchMoved = false ; return ; }
const state = loadFolderState ( ) ;
const isCollapsed = state [ folderName ] === false ;
state [ folderName ] = isCollapsed ? true : false ;
saveFolderState ( state ) ;
renderSessionList ( ) ;
} ) ;
// Allow renaming folder via double-click
header . addEventListener ( 'dblclick' , async ( e ) => {
e . stopPropagation ( ) ;
if ( e . target . closest ( '.folder-delete-btn' ) ) return ;
const newName = await styledPrompt ( 'Rename folder:' , {
title : 'Rename folder' ,
defaultValue : folderName ,
confirmText : 'Rename' ,
} ) ;
if ( ! newName || ! newName . trim ( ) || newName . trim ( ) === folderName ) return ;
const promises = folders [ folderName ] . map ( s => moveToFolder ( s . id , newName . trim ( ) ) ) ;
Promise . all ( promises ) . then ( ( ) => loadSessions ( ) ) ;
} ) ;
folderDiv . appendChild ( header ) ;
if ( ! collapsed ) {
const content = document . createElement ( 'div' ) ;
content . className = 'session-folder-content' ;
const folderSessions = folders [ folderName ] ;
const folderExpanded = _expandedFolders [ folderName ] ;
const folderLimit = folderExpanded ? folderSessions . length : FOLDER _MAX _VISIBLE ;
const visibleFolder = folderSessions . slice ( 0 , folderLimit ) ;
// Always include active session even if beyond limit
const activeInFolder = folderSessions . findIndex ( s => s . id === currentSessionId ) ;
if ( ! folderExpanded && activeInFolder >= folderLimit ) {
visibleFolder . push ( folderSessions [ activeInFolder ] ) ;
}
visibleFolder . forEach ( s => {
content . appendChild ( createSessionItem ( s ) ) ;
} ) ;
if ( folderSessions . length > FOLDER _MAX _VISIBLE ) {
const rem = folderSessions . length - FOLDER _MAX _VISIBLE ;
const moreBtn = document . createElement ( 'button' ) ;
moreBtn . className = 'session-show-more-btn' ;
moreBtn . textContent = folderExpanded ? 'Show less' : ` Show ${ rem } more ` ;
moreBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
_expandedFolders [ folderName ] = ! folderExpanded ;
renderSessionList ( ) ;
} ) ;
content . appendChild ( moreBtn ) ;
}
folderDiv . appendChild ( content ) ;
}
_frag . appendChild ( folderDiv ) ;
} ) ;
// Render unfiled sessions below folders (capped unless expanded)
const hasFolders = orderedFolderNames . length > 0 ;
const activeInUnfiled = unfiled . findIndex ( s => s . id === currentSessionId ) ;
const limit = _showAllSessions ? unfiled . length : SIDEBAR _MAX _VISIBLE ;
const visibleUnfiled = unfiled . slice ( 0 , limit ) ;
// If active session is beyond the limit, include it
if ( ! _showAllSessions && activeInUnfiled >= limit ) {
visibleUnfiled . push ( unfiled [ activeInUnfiled ] ) ;
}
// Wrap in "Unsorted" folder if real folders exist
let unfiledTarget = _frag ;
if ( hasFolders && unfiled . length > 0 ) {
const unsortedDiv = document . createElement ( 'div' ) ;
unsortedDiv . className = 'session-folder unsorted-folder' ;
const unsortedHeader = document . createElement ( 'div' ) ;
unsortedHeader . className = 'session-folder-header' ;
const unsortedCollapsed = loadFolderState ( ) [ '__unsorted__' ] === false ;
const dragHandle = document . createElement ( 'span' ) ;
dragHandle . className = 'folder-drag-handle' ;
dragHandle . textContent = '\u2630' ;
dragHandle . title = 'Drag to reorder folder' ;
unsortedHeader . appendChild ( dragHandle ) ;
const toggle = document . createElement ( 'span' ) ;
toggle . className = 'folder-toggle' ;
toggle . textContent = unsortedCollapsed ? '\u25B6' : '\u25BC' ;
unsortedHeader . appendChild ( toggle ) ;
const nameSpan = document . createElement ( 'span' ) ;
nameSpan . className = 'folder-name' ;
nameSpan . textContent = 'Unsorted' ;
unsortedHeader . appendChild ( nameSpan ) ;
const countSpan = document . createElement ( 'span' ) ;
countSpan . className = 'folder-count' ;
countSpan . textContent = ` ( ${ unfiled . length } ) ` ;
unsortedHeader . appendChild ( countSpan ) ;
const deleteBtn = document . createElement ( 'button' ) ;
deleteBtn . className = 'folder-delete-btn' ;
deleteBtn . textContent = '\u00d7' ;
deleteBtn . title = 'Delete all unsorted sessions' ;
deleteBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
if ( ! await uiModule . styledConfirm ( ` Delete all ${ unfiled . length } unsorted session(s)? ` , { confirmText : 'Delete' , danger : true } ) ) return ;
for ( const s of unfiled ) {
try {
await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } ` , { method : 'DELETE' } ) ;
_deselectCurrentSession ( s . id ) ;
} catch ( err ) {
console . error ( 'Failed to delete session:' , s . id , err ) ;
}
}
await loadSessions ( ) ;
} ) ;
unsortedHeader . appendChild ( deleteBtn ) ;
unsortedHeader . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
const state = loadFolderState ( ) ;
state [ '__unsorted__' ] = state [ '__unsorted__' ] === false ? true : false ;
saveFolderState ( state ) ;
renderSessionList ( ) ;
} ) ;
unsortedDiv . appendChild ( unsortedHeader ) ;
if ( ! unsortedCollapsed ) {
const content = document . createElement ( 'div' ) ;
content . className = 'session-folder-content' ;
unfiledTarget = content ;
unsortedDiv . appendChild ( content ) ;
}
_frag . appendChild ( unsortedDiv ) ;
if ( unsortedCollapsed ) {
unfiledTarget = null ;
}
}
if ( unfiledTarget ) {
visibleUnfiled . forEach ( s => {
unfiledTarget . appendChild ( createSessionItem ( s ) ) ;
} ) ;
}
// "Show more" / "Show less" toggle
if ( unfiledTarget && unfiled . length > SIDEBAR _MAX _VISIBLE ) {
const remaining = unfiled . length - SIDEBAR _MAX _VISIBLE ;
const toggleBtn = document . createElement ( 'button' ) ;
toggleBtn . className = 'session-show-more-btn' ;
toggleBtn . textContent = _showAllSessions ? 'Show less' : ` Show ${ remaining } more ` ;
toggleBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
_showAllSessions = ! _showAllSessions ;
renderSessionList ( ) ;
} ) ;
unfiledTarget . appendChild ( toggleBtn ) ;
}
// Flush all built elements into the list in one operation
list . innerHTML = '' ;
list . appendChild ( _frag ) ;
_postRenderSessionList ( list ) ;
}
/** Shared post-render: highlight, keyboard nav, swipe hint, drag sort */
function _postRenderSessionList ( list ) {
if ( currentSessionId ) {
const activeEl = document . querySelector ( ` .list-item[data-session-id=" ${ currentSessionId } "] ` ) ;
if ( activeEl ) {
activeEl . classList . add ( 'active-session' ) ;
if ( _sessionListFocused ) activeEl . focus ( ) ;
}
}
_initKeyboardNav ( list ) ;
_initSwipeToDelete ( list ) ;
initDragSort ( ) ;
_showSwipeHint ( list ) ;
}
function _initKeyboardNav ( list ) {
if ( ! list . _kbInit ) {
list . _kbInit = true ;
list . addEventListener ( 'keydown' , _onSessionListKeydown ) ;
list . addEventListener ( 'focusin' , ( ) => { _sessionListFocused = true ; } ) ;
list . addEventListener ( 'focusout' , ( e ) => {
if ( ! list . contains ( e . relatedTarget ) ) _sessionListFocused = false ;
} ) ;
}
}
function _initSwipeToDelete ( list ) {
// handled by existing swipe code — placeholder for consistency
}
function _showSwipeHint ( list ) {
if ( 'ontouchstart' in window && ! localStorage . getItem ( 'ody-swipe-hint-shown' ) ) {
const firstItem = list . querySelector ( '.session-item' ) ;
if ( firstItem ) {
localStorage . setItem ( 'ody-swipe-hint-shown' , '1' ) ;
const hint = document . createElement ( 'div' ) ;
hint . className = 'swipe-hint' ;
hint . innerHTML = '<span class="swipe-hint-arrow">\u2190</span> swipe to delete' ;
firstItem . style . position = 'relative' ;
firstItem . appendChild ( hint ) ;
setTimeout ( ( ) => { hint . style . opacity = '0' ; } , 3000 ) ;
setTimeout ( ( ) => { hint . remove ( ) ; } , 3500 ) ;
}
}
}
// ── Force sidebar open on mobile (after dropdown actions) ──
function _forceSidebarOpen ( ) {
if ( window . innerWidth > 768 ) return ;
// Suppress backdrop close
if ( window . _suppressSidebarClose !== undefined ) {
window . _suppressSidebarClose = true ;
setTimeout ( ( ) => { window . _suppressSidebarClose = false ; } , 2000 ) ;
}
// Force sidebar visible
requestAnimationFrame ( ( ) => {
const sb = document . getElementById ( 'sidebar' ) ;
if ( sb && sb . classList . contains ( 'hidden' ) ) {
sb . classList . remove ( 'hidden' ) ;
if ( window . syncRailSide ) window . syncRailSide ( ) ;
}
} ) ;
}
// While an inline rename is in progress on mobile, several paths can hide the
// sidebar (backdrop tap, soft-keyboard viewport resize, dropdown dismiss). Watch
// the sidebar directly and re-open it if anything hides it — bulletproof against
// whichever path fires. Returns a stopper to call once the rename is committed.
function _guardSidebarDuringRename ( ) {
if ( window . innerWidth > 768 || ! window . MutationObserver ) return ( ) => { } ;
const sb = document . getElementById ( 'sidebar' ) ;
if ( ! sb ) return ( ) => { } ;
const obs = new MutationObserver ( ( ) => {
if ( sb . classList . contains ( 'hidden' ) ) {
sb . classList . remove ( 'hidden' ) ;
const bd = document . getElementById ( 'sidebar-backdrop' ) ;
if ( bd ) bd . classList . add ( 'visible' ) ;
}
} ) ;
obs . observe ( sb , { attributes : true , attributeFilter : [ 'class' ] } ) ;
// Keep guarding briefly after the caller stops, to catch the keyboard-dismiss
// resize that fires just after blur/commit.
return ( ) => setTimeout ( ( ) => obs . disconnect ( ) , 400 ) ;
}
// ── Bulk select mode ──
let _selectMode = false ;
let _selectedIds = new Set ( ) ;
function _enterSelectMode ( ) {
_selectMode = true ;
_selectedIds . clear ( ) ;
const bulkBar = document . getElementById ( 'session-bulk-bar' ) ;
if ( bulkBar ) bulkBar . classList . remove ( 'hidden' ) ;
const selectBtn = document . getElementById ( 'session-select-btn' ) ;
if ( selectBtn ) selectBtn . style . opacity = '1' ;
// Add select dots to all session items
document . querySelectorAll ( '.list-item[data-session-id]' ) . forEach ( item => {
if ( item . querySelector ( '.session-select-cb' ) ) return ;
const dot = document . createElement ( 'span' ) ;
dot . className = 'session-select-cb' ;
dot . innerHTML = '○' ;
dot . style . cssText = 'cursor:pointer;font-size:16px;flex-shrink:0;opacity:0.4;transition:opacity 0.1s;user-select:none;' ;
dot . _checked = false ;
dot . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
dot . _checked = ! dot . _checked ;
dot . innerHTML = dot . _checked ? '●' : '○' ;
dot . style . opacity = dot . _checked ? '1' : '0.4' ;
dot . style . color = dot . _checked ? 'var(--accent, var(--red))' : '' ;
const sid = item . dataset . sessionId ;
if ( dot . _checked ) _selectedIds . add ( sid ) ;
else _selectedIds . delete ( sid ) ;
_updateBulkCount ( ) ;
} ) ;
item . insertBefore ( dot , item . firstChild ) ;
} ) ;
_updateBulkCount ( ) ;
}
function _exitSelectMode ( ) {
_selectMode = false ;
_selectedIds . clear ( ) ;
const bulkBar = document . getElementById ( 'session-bulk-bar' ) ;
if ( bulkBar ) bulkBar . classList . add ( 'hidden' ) ;
const selectBtn = document . getElementById ( 'session-select-btn' ) ;
if ( selectBtn ) selectBtn . style . opacity = '0.5' ;
const selectAll = document . getElementById ( 'session-select-all' ) ;
if ( selectAll ) selectAll . checked = false ;
// Remove checkboxes
document . querySelectorAll ( '.session-select-cb' ) . forEach ( cb => cb . remove ( ) ) ;
}
function _updateBulkCount ( ) {
const count = _selectedIds . size ;
const archiveBtn = document . getElementById ( 'session-bulk-archive' ) ;
const deleteBtn = document . getElementById ( 'session-bulk-delete' ) ;
if ( archiveBtn ) { archiveBtn . disabled = count === 0 ; archiveBtn . style . opacity = count === 0 ? '0.2' : '' ; }
if ( deleteBtn ) { deleteBtn . disabled = count === 0 ; deleteBtn . style . opacity = count === 0 ? '0.2' : '' ; }
}
function _initBulkSelect ( ) {
const selectBtn = document . getElementById ( 'session-select-btn' ) ;
if ( selectBtn ) {
selectBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
if ( _selectMode ) _exitSelectMode ( ) ;
else _enterSelectMode ( ) ;
} ) ;
}
const cancelBtn = document . getElementById ( 'session-bulk-cancel' ) ;
if ( cancelBtn ) cancelBtn . addEventListener ( 'click' , ( ) => _exitSelectMode ( ) ) ;
// Select from funnel dropdown
const selectFromDropdown = document . getElementById ( 'session-select-from-dropdown' ) ;
if ( selectFromDropdown ) {
selectFromDropdown . addEventListener ( 'click' , ( ) => {
const dd = document . getElementById ( 'session-sort-dropdown' ) ;
if ( dd ) dd . style . display = 'none' ;
_enterSelectMode ( ) ;
} ) ;
}
// Escape exits select mode
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Escape' && _selectMode ) {
_exitSelectMode ( ) ;
}
} ) ;
const selectAll = document . getElementById ( 'session-select-all' ) ;
const selectAllDot = document . getElementById ( 'session-select-all-dot' ) ;
const selectAllLabel = document . getElementById ( 'session-select-all-label' ) ;
if ( selectAll && selectAllDot ) {
const _toggleAll = ( ) => {
selectAll . checked = ! selectAll . checked ;
selectAllDot . innerHTML = selectAll . checked ? '●' : '○' ;
selectAllDot . style . opacity = selectAll . checked ? '1' : '0.4' ;
selectAllDot . style . color = selectAll . checked ? 'var(--accent, var(--red))' : '' ;
document . querySelectorAll ( '.session-select-cb' ) . forEach ( dot => {
dot . _checked = selectAll . checked ;
dot . innerHTML = selectAll . checked ? '●' : '○' ;
dot . style . opacity = selectAll . checked ? '1' : '0.4' ;
dot . style . color = selectAll . checked ? 'var(--accent, var(--red))' : '' ;
const sid = dot . closest ( '[data-session-id]' ) ? . dataset . sessionId ;
if ( sid ) {
if ( selectAll . checked ) _selectedIds . add ( sid ) ;
else _selectedIds . delete ( sid ) ;
}
} ) ;
_updateBulkCount ( ) ;
} ;
selectAllDot . addEventListener ( 'click' , _toggleAll ) ;
if ( selectAllLabel ) selectAllLabel . addEventListener ( 'click' , _toggleAll ) ;
}
const archiveBtn = document . getElementById ( 'session-bulk-archive' ) ;
if ( archiveBtn ) {
archiveBtn . addEventListener ( 'click' , async ( ) => {
if ( _selectedIds . size === 0 ) return ;
const count = _selectedIds . size ;
if ( ! await uiModule . styledConfirm ( ` Archive ${ count } session(s)? ` , { confirmText : 'Archive' } ) ) return ;
for ( const sid of _selectedIds ) {
try {
await fetch ( ` ${ API _BASE } /api/session/ ${ sid } /archive ` , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } } ) ;
} catch ( _ ) { }
}
_exitSelectMode ( ) ;
if ( window . _suppressSidebarClose !== undefined ) { window . _suppressSidebarClose = true ; setTimeout ( ( ) => { window . _suppressSidebarClose = false ; } , 1500 ) ; }
await loadSessions ( ) ;
uiModule . showToast ( ` ${ count } session(s) archived ` ) ;
} ) ;
}
const deleteBtn = document . getElementById ( 'session-bulk-delete' ) ;
if ( deleteBtn ) {
deleteBtn . addEventListener ( 'click' , async ( ) => {
if ( _selectedIds . size === 0 ) return ;
const count = _selectedIds . size ;
if ( ! await uiModule . styledConfirm ( ` Delete ${ count } session(s)? This cannot be undone. ` , { confirmText : 'Delete' , danger : true } ) ) return ;
const deletedIds = [ ] ;
for ( const sid of _selectedIds ) {
try {
const res = await fetch ( ` ${ API _BASE } /api/session/ ${ sid } ` , { method : 'DELETE' } ) ;
if ( res . ok ) deletedIds . push ( sid ) ;
} catch ( _ ) { }
}
await _animateSessionRowsRemoving ( deletedIds , '#session-list .list-item[data-session-id]' ) ;
_exitSelectMode ( ) ;
if ( window . _suppressSidebarClose !== undefined ) { window . _suppressSidebarClose = true ; setTimeout ( ( ) => { window . _suppressSidebarClose = false ; } , 1500 ) ; }
await loadSessions ( ) ;
uiModule . showToast ( ` ${ deletedIds . length } session(s) deleted ` ) ;
} ) ;
}
}
function _animateSessionRowsRemoving ( ids , selector ) {
const idSet = new Set ( ( ids || [ ] ) . map ( id => String ( id ) ) ) ;
if ( ! idSet . size ) return Promise . resolve ( ) ;
const rows = Array . from ( document . querySelectorAll ( selector || '.list-item[data-session-id]' ) )
. filter ( row => idSet . has ( String ( row . dataset . sessionId || row . dataset . sid ) ) ) ;
if ( ! rows . length ) return Promise . resolve ( ) ;
for ( const row of rows ) {
row . style . maxHeight = ` ${ Math . max ( row . getBoundingClientRect ( ) . height , row . scrollHeight ) } px ` ;
row . classList . add ( 'memory-tidy-removing' ) ;
}
return new Promise ( resolve => setTimeout ( resolve , 520 ) ) ;
}
export async function loadSessions ( ) {
try {
// Delete incognito sessions left over from a previous page load
await _cleanupIncognitoSessions ( ) ;
// Use prefetched data from login page if available (first load only)
const prefetched = sessionStorage . getItem ( 'ody-prefetch-sessions' ) ;
let fetched ;
if ( prefetched ) {
sessionStorage . removeItem ( 'ody-prefetch-sessions' ) ;
fetched = JSON . parse ( prefetched ) ;
} else {
const res = await fetch ( ` ${ API _BASE } /api/sessions ` ) ;
fetched = await res . json ( ) ;
}
2026-06-02 09:52:22 -05:00
sessions = _normalizeSessionsList ( fetched ) ;
2026-05-31 23:58:26 +09:00
renderSessionList ( ) ;
const sessionsSection = uiModule . el ( 'sessions-section' ) ;
if ( sessions . length === 0 ) {
sessionsSection . classList . add ( 'hidden' ) ;
} else {
sessionsSection . classList . remove ( 'hidden' ) ;
}
const activeSessions = sessions . filter ( s => ! s . archived ) ;
// "Transient" sessions = the singleton Assistant chat + any task-output
// session. Treat them as not-restorable so coming back to the app lands
// on the user's last actual conversation, not whichever check-in task
// most recently appended a message.
const _isTransient = ( s ) => ! ! s && ( s . folder === 'Assistant' || s . folder === 'Tasks' ) ;
const _realSessions = activeSessions . filter ( s => ! _isTransient ( s ) ) ;
const hashId = window . location . hash . replace ( '#' , '' ) ;
let savedId = Storage . get ( 'lastSessionId' ) ;
// If the persisted lastSessionId points to a transient session (legacy
// state from before the persistence-guard was added), drop it.
if ( savedId ) {
const _saved = activeSessions . find ( s => s . id === savedId ) ;
if ( _saved && _isTransient ( _saved ) ) {
Storage . remove ( 'lastSessionId' ) ;
savedId = null ;
}
}
const hasPendingChat = ! ! _pendingChat ;
let targetId = null ;
if ( hasPendingChat ) {
// A model was picked and the UI is showing a fresh New Chat, but the
// session is not created until the first message. Background stream
// completions call loadSessions() later; without this guard that reload
// sees no current session and auto-selects the previous chat.
targetId = null ;
} else if ( hashId && activeSessions . some ( s => s . id === hashId ) ) {
targetId = hashId ;
} else if ( currentSessionId && activeSessions . some ( s => s . id === currentSessionId ) ) {
targetId = currentSessionId ;
} else if ( currentSessionId ) {
// Session was just created but may not be in the list yet — keep it
targetId = currentSessionId ;
} else if ( savedId && activeSessions . some ( s => s . id === savedId ) ) {
targetId = savedId ;
} else if ( ! _skipAutoSelect && _realSessions . length > 0 ) {
// Most-recent NON-transient session — skip Assistant / Tasks so the
// auto-firing assistant doesn't become the apparent default chat.
targetId = _realSessions [ 0 ] . id ;
} else if ( ! _skipAutoSelect && activeSessions . length > 0 ) {
// Only transient sessions exist (brand-new account) — fall through to
// the original behaviour so we don't leave the user with nothing.
targetId = activeSessions [ 0 ] . id ;
}
_skipAutoSelect = false ;
// Fresh login: prefer a default-model session so a brand-new user lands
// ready to chat. CRITICAL: only do this when there's NO session to return
// to (no hash / lastSessionId / existing chat resolved into targetId).
// Otherwise a fresh page load — which a server restart triggers — would
// spin up a new empty default-model chat and shadow the user's last
// conversation, making it look like the chat "lost its context" (and the
// picker would still show the old model's name from cached state). See
// the targetId resolution above (hash → currentSession → lastSessionId →
// most-recent).
const _isFirstLoad = ! sessionStorage . getItem ( 'ody-session-active' ) ;
if ( _isFirstLoad ) {
sessionStorage . setItem ( 'ody-session-active' , '1' ) ;
if ( ! targetId ) {
try {
const dcRes = await fetch ( ` ${ API _BASE } /api/default-chat ` ) ;
const dc = await dcRes . json ( ) ;
if ( dc . endpoint _url && dc . model ) {
// Check if there's already an empty session with this model we can reuse
const emptyDefault = activeSessions . find ( s =>
s . model === dc . model && s . message _count === 0
) ;
if ( emptyDefault ) {
targetId = emptyDefault . id ;
} else {
await createDirectChat ( dc . endpoint _url , dc . model , dc . endpoint _id ) ;
// On mobile, hide sidebar so user lands directly in chat
if ( window . innerWidth < 768 ) {
const sb = document . getElementById ( 'sidebar' ) ;
if ( sb ) sb . classList . add ( 'hidden' ) ;
}
return ; // createDirectChat handles selectSession internally
}
}
} catch ( _ ) { /* no default model configured */ }
}
}
if ( targetId && targetId !== currentSessionId ) {
await selectSession ( targetId , { keepSidebar : true } ) ;
} else if ( targetId && targetId === currentSessionId ) {
// Same session — just refresh the header name in case it was auto-generated
const s = sessions . find ( x => x . id === targetId ) ;
const metaEl = document . getElementById ( 'current-meta' ) ;
if ( metaEl && s ) metaEl . textContent = s . name ;
}
// No session selected — still enable input so slash commands (e.g. /setup) work
if ( ! targetId && ! hasPendingChat ) {
const msgInput = document . getElementById ( 'message' ) ;
if ( msgInput ) {
msgInput . disabled = false ;
if ( window . innerWidth > 768 ) msgInput . focus ( ) ;
}
if ( window . chatModule && window . chatModule . showWelcomeScreen ) {
window . chatModule . showWelcomeScreen ( ) ;
}
updateModelPicker ( ) ;
// Only auto-create if there are truly zero sessions (not just unselected)
if ( activeSessions . length === 0 && ! _autoCreateInProgress ) {
_autoCreateInProgress = true ;
try {
const dcRes = await fetch ( ` ${ API _BASE } /api/default-chat ` ) ;
const dc = await dcRes . json ( ) ;
if ( dc . endpoint _url && dc . model ) {
await createDirectChat ( dc . endpoint _url , dc . model , dc . endpoint _id ) ;
}
} catch ( _ ) { /* no default model — that's fine, user can /setup */ }
_autoCreateInProgress = false ;
}
}
} catch ( error ) {
console . error ( 'Error in loadSessions:' , error ) ;
uiModule . showError ( 'Failed to load sessions: ' + error . message ) ;
}
}
export async function selectSession ( id , { keepSidebar = false } = { } ) {
// Exit compare mode cleanly if active
if ( window . compareModule && window . compareModule . isActive ( ) ) {
window . compareModule . deactivate ( true ) ;
return ; // deactivate does a page reload
}
try {
const navToken = ++ _sessionNavToken ;
const prevSessionId = currentSessionId ;
// Re-archive peeked session when navigating away
_checkPeekCleanup ( id ) ;
// Clear any leftover document text selection so it doesn't bleed into the new chat
if ( prevSessionId !== id && window . documentModule ? . clearSelection ) {
try { window . documentModule . clearSelection ( ) ; } catch { }
}
currentSessionId = id ;
// Identify Assistant / task-output sessions so we don't "trap" the user
// there on return. Skipped from both `lastSessionId` persistence and the
// URL hash — the user complained that coming back to Odysseus kept
// landing them on the auto-firing task-log chat instead of their last
// real conversation.
const _meta = sessions . find ( s => s . id === id ) ;
const _isTransientChat = ! ! _meta && ( _meta . folder === 'Assistant' || _meta . folder === 'Tasks' ) ;
if ( ! _isTransientChat ) {
Storage . set ( 'lastSessionId' , id ) ;
// Update URL hash without triggering hashchange handler
if ( window . location . hash !== '#' + id ) {
history . replaceState ( null , '' , '#' + id ) ;
}
}
// Restore character preset for persistent chats
try {
const presetsModule = window . presetsModule || ( await import ( './presets.js' ) ) . default ;
if ( presetsModule && presetsModule . onSessionSwitch ) presetsModule . onSessionSwitch ( id ) ;
} catch ( e ) { }
const meta = sessions . find ( s => s . id === id ) ;
// Detach any in-flight stream to background instead of aborting
try {
if ( window . chatModule ) {
if ( window . chatModule . detachCurrentStream ) {
window . chatModule . detachCurrentStream ( prevSessionId ) ;
} else if ( window . chatModule . abortCurrentRequest ) {
window . chatModule . abortCurrentRequest ( ) ;
}
}
} catch ( e ) {
console . warn ( 'detachCurrentStream error:' , e ) ;
if ( window . chatModule && window . chatModule . abortCurrentRequest ) {
window . chatModule . abortCurrentRequest ( ) ;
}
}
// Reset send button to idle state
if ( window . _updateSendBtnIcon ) window . _updateSendBtnIcon ( ) ;
const sendBtn = document . querySelector ( '.send-btn' ) ;
if ( sendBtn && sendBtn . dataset . mode === 'streaming' ) {
sendBtn . dataset . mode = '' ;
sendBtn . innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>' ;
sendBtn . title = 'Send message' ;
}
// Deactivate compare mode on session switch
if ( window . compareModule ) {
if ( window . compareModule . isActive ( ) ) window . compareModule . deactivate ( true ) ;
else if ( window . compareModule . hasVisibleResults ( ) ) window . compareModule . cleanupResults ( ) ;
}
const msgInput = document . getElementById ( 'message' ) ;
if ( msgInput ) {
msgInput . disabled = false ;
msgInput . value = '' ;
}
const sendBtn2 = document . querySelector ( '.send-btn' ) ;
if ( sendBtn2 ) {
sendBtn2 . style . color = '' ;
if ( window . _updateSendBtnIcon ) window . _updateSendBtnIcon ( ) ;
}
// On mobile, keep sidebar open — user dismisses it by tapping chat area or swiping
// Highlight active session in sidebar
document . querySelectorAll ( '.list-item.active-session' ) . forEach ( el => el . classList . remove ( 'active-session' ) ) ;
const activeEl = document . querySelector ( ` .list-item[data-session-id=" ${ id } "] ` ) ;
if ( activeEl ) activeEl . classList . add ( 'active-session' ) ;
const currentMetaEl = uiModule . el ( 'current-meta' ) ;
if ( currentMetaEl ) {
currentMetaEl . textContent = meta ? meta . name : 'Odysseus Chat' ;
}
// Update model picker visibility
updateModelPicker ( ) ;
// Refresh session cost badge for the newly selected session
if ( chatRenderer . updateSessionCostUI ) chatRenderer . updateSessionCostUI ( ) ;
const chatHistory = uiModule . el ( 'chat-history' ) ;
// Prefetch history before fading so we can swap instantly. `isOC`
// is the OpenClaw special-session sentinel — used by the wouldWipe
// guard below and the welcome-screen branch further down. (Its
// declaration had been removed while leaving the references in
// place, producing a ReferenceError every selectSession.)
const isOC = meta && ( meta . is _openclaw || id === 'openclaw' ) ;
let msgHistory = [ ] , modelName = null ;
if ( ! isOC ) {
const res = await fetch ( ` ${ API _BASE } /api/history/ ${ id } ` ) ;
const data = await res . json ( ) ;
if ( navToken !== _sessionNavToken || currentSessionId !== id ) return ;
msgHistory = data . history || [ ] ;
modelName = data . model || null ;
// The model returned by /api/history is the authoritative one the
// backend will use for this session. Write it back into the cached
// session meta and refresh the picker so the displayed model can
// never diverge from what's actually sent (the "picker says Minimax
// but it used the default" bug after a restart / stale cache).
if ( modelName ) {
const sMeta = sessions . find ( s => s . id === id ) ;
if ( sMeta && sMeta . model !== modelName ) {
sMeta . model = modelName ;
updateModelPicker ( ) ;
}
}
}
// Guard: if the fetched history is empty but the DOM already has message
// bubbles for the same session (incognito doesn't persist, so /api/history
// returns []), preserve the DOM instead of wiping it. This fixes the
// "reply flashes for 0.1s then empty" bug when selectSession is called
// after a streaming completion in an incognito chat.
const isSameSession = ( prevSessionId === id ) ;
const hasExistingBubbles = chatHistory && chatHistory . querySelectorAll ( '.msg' ) . length > 0 ;
const wouldWipe = ! isOC && ! msgHistory . length && isSameSession && hasExistingBubbles ;
if ( wouldWipe ) {
// Skip the fade/reload; we're already showing the right content.
if ( chatHistory ) chatHistory . classList . remove ( 'no-animate' ) ;
return ;
}
// Fade out old content, swap, fade in
if ( chatHistory ) {
chatHistory . style . transition = 'opacity 0.12s ease-out' ;
chatHistory . style . opacity = '0' ;
await new Promise ( r => setTimeout ( r , 120 ) ) ;
if ( navToken !== _sessionNavToken || currentSessionId !== id ) return ;
chatHistory . innerHTML = '' ;
}
// Suppress per-message entrance animations during bulk history render
if ( chatHistory ) chatHistory . classList . add ( 'no-animate' ) ;
// Populate new content while invisible
if ( isOC ) {
if ( window . chatModule && window . chatModule . showWelcomeScreen ) window . chatModule . showWelcomeScreen ( ) ;
window . chatModule . addMessage ( 'assistant' ,
` <p> \u D83E \u DD9E <strong>OpenClaw Agent Connected</strong></p>
< p > Messages will be routed through your OpenClaw agent . The agent has access to tools , memory , and skills configured in your OpenClaw workspace . < / p > ` ,
'OpenClaw' ) ;
} else if ( msgHistory . length ) {
for ( const msg of msgHistory ) {
const meta = msg . metadata ? { ... msg . metadata , _fromHistory : true } : null ;
2026-06-02 15:37:48 +02:00
let displayContent ;
if ( typeof msg . content === 'string' ) {
displayContent = msg . content ;
} else if ( Array . isArray ( msg . content ) ) {
// Multimodal (image/audio attachments): extract text parts, skip binary
displayContent = msg . content . filter ( p => p . type === 'text' ) . map ( p => p . text ) . join ( '\n' ) . trim ( ) ;
} else {
displayContent = '' ;
}
2026-05-31 23:58:26 +09:00
// Clean up doc selection context for display
if ( msg . role === 'user' ) {
// Hide "Continue where you left off" bubbles
if ( displayContent . trim ( ) === 'Continue where you left off' || displayContent . trim ( ) . startsWith ( 'Your message was cut off.' ) || displayContent . trim ( ) . startsWith ( 'Your previous response was interrupted.' ) || displayContent . includes ( '[Instruction: Rewrite' ) || displayContent . includes ( '[Instruction: Explain' ) ) continue ;
const docEditMatch = displayContent . match ( /^In the document, edit this specific text \((lines? [\d-]+)\):\n```\n([\s\S]*?)\n```\n\nInstruction: ([\s\S]*)$/ ) ;
if ( docEditMatch ) {
displayContent = ` [Doc edit: ${ docEditMatch [ 1 ] } ] ${ docEditMatch [ 3 ] } ` ;
}
}
window . chatModule . addMessage ( msg . role , markdownModule . renderContent ( displayContent ) , modelName , meta ) ;
}
} else {
if ( window . chatModule && window . chatModule . showWelcomeScreen ) window . chatModule . showWelcomeScreen ( ) ;
// Don't highlight empty sessions — feels like nothing is selected
document . querySelectorAll ( '.list-item.active-session' ) . forEach ( el => el . classList . remove ( 'active-session' ) ) ;
}
uiModule . scrollHistoryInstant ( ) ;
// Fade in and re-enable message animations
if ( chatHistory ) {
chatHistory . style . transition = 'opacity 0.15s ease-in' ;
chatHistory . style . opacity = '1' ;
chatHistory . classList . remove ( 'no-animate' ) ;
}
if ( window . hljs ) {
document . querySelectorAll ( 'pre code:not(.hljs)' ) . forEach ( block => {
window . hljs . highlightElement ( block ) ;
} ) ;
}
// Hide research button on session switch — it's only for the session that started it
var _rBtn = document . getElementById ( 'research-toggle-btn' ) ;
var _rChk = document . getElementById ( 'research-toggle' ) ;
if ( _rBtn ) _rBtn . style . display = 'none' ;
if ( _rChk ) _rChk . checked = false ;
// Check for pending/completed research that survived a page refresh
if ( window . chatModule && window . chatModule . checkPendingResearch ) {
window . chatModule . checkPendingResearch ( id ) ;
}
// Restore group chat state if this is a group session
if ( window . groupModule && window . groupModule . restoreState && window . groupModule . restoreState ( id ) ) {
if ( window . _syncGroupIndicator ) window . _syncGroupIndicator ( true ) ;
// Hide model picker for group sessions
const _mpw = document . getElementById ( 'model-picker-wrap' ) ;
if ( _mpw ) _mpw . style . display = 'none' ;
} else if ( window . groupModule && window . groupModule . isActive ( ) ) {
// Switching away from group session — deactivate
window . groupModule . stopGroup ( ) ;
if ( window . _syncGroupIndicator ) window . _syncGroupIndicator ( false ) ;
}
// Stop pulsing notification — user is now viewing this session
clearStreamComplete ( id ) ;
// Re-attach any background stream
try {
if ( window . chatModule && window . chatModule . checkBackgroundStream ) {
window . chatModule . checkBackgroundStream ( id ) ;
}
} catch ( e ) {
console . warn ( 'checkBackgroundStream error:' , e ) ;
}
// Check server for active stream (survives page refresh)
_checkServerStream ( id ) ;
// Document panel: keep open if next session also wants it, otherwise close
if ( window . documentModule ) {
const docBtn = document . getElementById ( 'overflow-doc-btn' ) ;
const meta = sessions . find ( s => s . id === id ) ;
const shouldOpen = localStorage . getItem ( 'odysseus-doc-open-' + id ) === '1' ;
const hasDocs = ! ! ( meta && meta . has _documents ) ;
if ( docBtn ) {
docBtn . classList . remove ( 'active' ) ;
docBtn . classList . toggle ( 'has-docs' , hasDocs ) ;
}
const docInd = document . getElementById ( 'doc-indicator-btn' ) ;
if ( docInd ) docInd . classList . toggle ( 'visible' , hasDocs ) ;
if ( hasDocs ) {
// Wait for session UI to settle, then slide in documents
setTimeout ( ( ) => window . documentModule . loadSessionDocs ( id , { restoreMode : true } ) , 300 ) ;
} else if ( ! shouldOpen ) {
window . documentModule . closePanel ( ) ;
}
}
} catch ( error ) {
console . error ( 'Error in selectSession:' , error ) ;
uiModule . showError ( 'Failed to load session: ' + error . message ) ;
} finally {
// Ensure memories are loaded after session selection
if ( window . memoryModule && window . memoryModule . loadMemories ) {
await window . memoryModule . loadMemories ( ) ;
}
// Auto-focus message input (unless session list has keyboard focus).
// Skip on mobile — focusing the textarea pops up the on-screen keyboard,
// which is intrusive when the user is just navigating between chats
// (e.g. picking a chat from the Library). They can tap the input to
// bring up the keyboard when they actually want to type.
if ( ! _sessionListFocused && window . innerWidth > 768 ) {
const msgInput = document . getElementById ( 'message' ) ;
if ( msgInput ) msgInput . focus ( ) ;
}
}
}
// Pending session — stored locally until the first message is sent
let _pendingChat = null ; // { url, modelId, endpointId }
export function createDirectChat ( url , modelId , endpointId ) {
_sessionNavToken ++ ;
// Detach any active stream so it doesn't interfere with the new chat
if ( window . chatModule && window . chatModule . detachCurrentStream ) {
window . chatModule . detachCurrentStream ( currentSessionId ) ;
}
// Stop an active GROUP chat too — otherwise its in-flight parallel/round-robin
// streams keep rendering into the brand-new chat (abort the group's fetches).
if ( window . groupModule && window . groupModule . isActive && window . groupModule . isActive ( ) ) {
try { window . groupModule . stopGroup ( ) ; } catch { }
if ( window . _syncGroupIndicator ) window . _syncGroupIndicator ( false ) ;
}
// Don't hit the API — just store the model info and prepare the UI
_pendingChat = { url , modelId , endpointId } ;
_skipAutoSelect = true ;
currentSessionId = null ;
Storage . remove ( 'lastSessionId' ) ;
history . replaceState ( null , '' , window . location . pathname ) ;
document . querySelectorAll ( '.list-item.active-session, .session-item.active' ) . forEach ( el => {
el . classList . remove ( 'active-session' , 'active' ) ;
} ) ;
// Close document panel — new chat has no docs
if ( window . documentModule && window . documentModule . isPanelOpen ( ) ) {
window . documentModule . closePanel ( ) ;
}
const docBtn = document . getElementById ( 'overflow-doc-btn' ) ;
if ( docBtn ) {
docBtn . classList . remove ( 'active' , 'has-docs' ) ;
docBtn . style . display = '' ; // show in overflow menu again
}
const docInd = document . getElementById ( 'doc-indicator-btn' ) ;
if ( docInd ) docInd . classList . remove ( 'visible' , 'active' ) ;
// Clear chat area and show welcome
const box = document . getElementById ( 'chat-history' ) ;
if ( box ) box . innerHTML = '' ;
if ( window . chatModule && window . chatModule . showWelcomeScreen ) {
window . chatModule . showWelcomeScreen ( ) ;
}
// Update model picker to show the pending model
updateModelPicker ( ) ;
// Update current-meta header
const metaEl = document . getElementById ( 'current-meta' ) ;
if ( metaEl ) {
metaEl . textContent = 'New Chat' ;
}
// Enable input
const msgInput = document . getElementById ( 'message' ) ;
if ( msgInput ) { msgInput . disabled = false ; msgInput . value = '' ; msgInput . focus ( ) ; }
}
/** Actually create the session in the DB. Called on first message send. */
export async function materializePendingSession ( ) {
const pending = _pendingChat ;
if ( ! pending ) return false ;
_pendingChat = null ;
const incognitoChk = document . getElementById ( 'incognito-toggle' ) ;
const isIncognito = incognitoChk && incognitoChk . checked ;
const base = ( pending . modelId || 'model' ) . split ( '/' ) . pop ( ) ;
const name = isIncognito ? 'Nobody' : ` ${ base } ${ new Date ( ) . toLocaleTimeString ( ) } ` ;
const fd = new FormData ( ) ;
fd . append ( 'name' , name ) ;
fd . append ( 'endpoint_url' , pending . url || '' ) ;
fd . append ( 'model' , pending . modelId || '' ) ;
if ( pending . url && pending . modelId ) {
fd . append ( 'skip_validation' , 'true' ) ;
}
if ( pending . endpointId ) {
fd . append ( 'endpoint_id' , pending . endpointId ) ;
}
let res ;
try {
res = await fetch ( ` ${ API _BASE } /api/session ` , { method : 'POST' , body : fd } ) ;
} catch ( e ) {
uiModule . showError ( 'Failed to reach backend: ' + e ) ;
return false ;
}
let payload ;
try {
payload = await res . json ( ) ;
} catch {
payload = { detail : await res . text ( ) } ;
}
if ( ! res . ok ) {
uiModule . showError ( ` Session create failed ( ${ res . status } ) ${ payload . detail || JSON . stringify ( payload ) } ` ) ;
return false ;
}
if ( isIncognito && payload . id ) {
_markIncognito ( payload . id ) ;
}
// Clear any leftover document text selection from the previous session
if ( window . documentModule ? . clearSelection ) {
try { window . documentModule . clearSelection ( ) ; } catch { }
}
currentSessionId = payload . id ;
Storage . set ( 'lastSessionId' , payload . id ) ;
history . replaceState ( null , '' , '#' + payload . id ) ;
// Reload sidebar to show the new session — await it so the session
// is fully registered before the caller proceeds (prevents race conditions)
await loadSessions ( ) . catch ( ( ) => { } ) ;
return true ;
}
export function hasPendingChat ( ) { return ! ! _pendingChat ; }
export function getPendingChat ( ) { return _pendingChat ; }
// Getters for external access
export function getCurrentSessionId ( ) {
return currentSessionId ;
}
export function getSessions ( ) {
return sessions ;
}
export function getCurrentModel ( ) {
const sess = sessions . find ( x => x . id === currentSessionId ) ;
if ( sess && sess . model ) return sess . model ;
// Pending session not yet materialized — read from model picker label
const label = document . getElementById ( 'model-picker-label' ) ;
return label ? label . textContent . trim ( ) : null ;
}
/ * * E n d p o i n t U R L s e r v i n g t h e c u r r e n t ( o r p e n d i n g ) s e s s i o n ' s m o d e l . U s e d t o
* decide whether a model is local ( free ) vs a billable cloud provider . * /
export function getCurrentEndpointUrl ( ) {
const sess = sessions . find ( x => x . id === currentSessionId ) ;
if ( sess && sess . endpoint _url ) return sess . endpoint _url ;
if ( _pendingChat && _pendingChat . url ) return _pendingChat . url ;
return null ;
}
export function setCurrentSessionId ( id ) {
_sessionNavToken ++ ;
currentSessionId = id ;
if ( ! id ) {
Storage . remove ( 'lastSessionId' ) ;
history . replaceState ( null , '' , window . location . pathname ) ;
document . querySelectorAll ( '.list-item.active-session, .session-item.active' ) . forEach ( el => {
el . classList . remove ( 'active-session' , 'active' ) ;
} ) ;
}
}
// Session list keyboard navigation: arrows to move, Delete to delete
2026-06-02 07:43:34 -04:00
async function _onSessionListKeydown ( e ) {
2026-05-31 23:58:26 +09:00
const item = e . target . closest ( '.list-item[data-session-id]' ) ;
if ( ! item ) return ;
if ( e . key === 'ArrowDown' || e . key === 'ArrowUp' ) {
e . preventDefault ( ) ;
// Get all visible session items across all containers
const allItems = Array . from ( document . querySelectorAll ( '#session-list .list-item[data-session-id]' ) ) ;
const idx = allItems . indexOf ( item ) ;
if ( idx < 0 ) return ;
const next = e . key === 'ArrowDown' ? allItems [ idx + 1 ] : allItems [ idx - 1 ] ;
if ( next ) {
next . focus ( ) ;
const sid = next . dataset . sessionId ;
if ( sid ) selectSession ( sid ) ;
}
return ;
}
if ( e . key === 'Delete' || e . key === 'Backspace' ) {
e . preventDefault ( ) ;
const sid = item . dataset . sessionId ;
const s = sessions . find ( x => x . id === sid ) ;
if ( ! s ) return ;
if ( s . is _important ) {
uiModule . showToast ( 'Unfavorite before deleting' ) ;
return ;
}
2026-06-02 07:43:34 -04:00
const ok = await uiModule . styledConfirm ( 'Delete this session?' , { confirmText : 'Delete' , danger : true } ) ;
if ( ! ok ) return ;
2026-05-31 23:58:26 +09:00
_sessionListFocused = true ;
( async ( ) => {
await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } ` , { method : 'DELETE' } ) ;
_deselectCurrentSession ( s . id ) ;
await loadSessions ( ) ;
} ) ( ) ;
return ;
}
if ( e . key === 'Enter' ) {
e . preventDefault ( ) ;
const sid = item . dataset . sessionId ;
if ( sid ) selectSession ( sid ) ;
return ;
}
}
// Initialize drag sorting for sessions — uses the same dragSortModule as models
export function initDragSort ( ) {
if ( ! window . dragSortModule ) return ;
const list = uiModule . el ( 'session-list' ) ;
if ( ! list ) return ;
// Unfiled sessions (exclude items nested inside folders)
window . dragSortModule . enable ( 'session-list' , '.list-item' , {
instanceKey : 'session-items' ,
handleSelector : '.item-drag-handle' ,
excludeSelector : '.session-folder-content .list-item' ,
storageKey : 'session-order' ,
} ) ;
// Folder reordering
window . dragSortModule . enable ( 'session-list' , '.session-folder' , {
instanceKey : 'session-folders' ,
handleSelector : '.folder-drag-handle' ,
onReorder : ( items ) => {
const order = items . map ( f => f . dataset . folderName ) . filter ( Boolean ) ;
saveFolderOrder ( order ) ;
} ,
} ) ;
// Sessions within each folder
list . querySelectorAll ( '.session-folder-content' ) . forEach ( ( content , i ) => {
const id = 'session-folder-content-' + i ;
content . id = id ;
window . dragSortModule . enable ( id , '.list-item' , {
handleSelector : '.item-drag-handle' ,
} ) ;
} ) ;
}
2026-06-05 06:05:30 -07:00
// Hash-based routing: navigate between sessions with browser back/forward.
// Skip entity-prefixed hashes (document-, note-, etc.) — those are handled
// by their own click handlers in chatRenderer.js and must not trigger
// session navigation (which would reset the active chat).
2026-05-31 23:58:26 +09:00
window . addEventListener ( 'hashchange' , ( ) => {
const hashId = window . location . hash . replace ( '#' , '' ) ;
2026-06-05 06:05:30 -07:00
if ( /^(document|note|image|email|event|task|skill|research)-/ . test ( hashId ) ) return ;
2026-05-31 23:58:26 +09:00
if ( hashId && hashId !== currentSessionId ) {
const target = sessions . find ( s => s . id === hashId && ! s . archived ) ;
if ( target ) selectSession ( hashId ) ;
}
} ) ;
// ── Research indicator management ──
function _updateResearchDots ( ) {
document . querySelectorAll ( '.session-star[data-session-id]' ) . forEach ( function ( star ) {
var sid = star . dataset . sessionId ;
var isRunning = _researchingSessions . has ( sid ) || _streamingSessions . has ( sid ) ;
var isCompleted = _completedSessions . has ( sid ) && ! isRunning ;
var listItem = star . closest ( '.list-item' ) ;
star . classList . toggle ( 'processing' , isRunning ) ;
star . classList . toggle ( 'notify' , isCompleted ) ;
if ( listItem ) listItem . classList . toggle ( 'stream-complete' , isCompleted ) ;
if ( isRunning || isCompleted ) {
star . style . opacity = '1' ;
} else {
star . style . opacity = '' ;
}
} ) ;
}
function _startResearchPolling ( ) {
if ( _researchPollTimer ) return ;
_researchPollTimer = setInterval ( async function ( ) {
if ( _researchingSessions . size === 0 ) {
clearInterval ( _researchPollTimer ) ;
_researchPollTimer = null ;
return ;
}
for ( var sid of _researchingSessions ) {
try {
var res = await fetch ( ` ${ API _BASE } /api/research/status/ ${ sid } ` ) ;
if ( ! res . ok ) { _researchingSessions . delete ( sid ) ; continue ; }
var data = await res . json ( ) ;
if ( data . status !== 'running' ) {
_researchingSessions . delete ( sid ) ;
}
} catch ( e ) {
_researchingSessions . delete ( sid ) ;
}
}
_updateResearchDots ( ) ;
if ( _researchingSessions . size === 0 && _researchPollTimer ) {
clearInterval ( _researchPollTimer ) ;
_researchPollTimer = null ;
}
} , 5000 ) ;
}
export function markResearching ( sessionId ) {
_researchingSessions . add ( sessionId ) ;
_updateResearchDots ( ) ;
_updateRailNotifs ( ) ;
_startResearchPolling ( ) ;
}
export function clearResearching ( sessionId ) {
_researchingSessions . delete ( sessionId ) ;
_updateResearchDots ( ) ;
_updateRailNotifs ( ) ;
}
export function markStreaming ( sessionId ) {
_streamingSessions . add ( sessionId ) ;
_updateResearchDots ( ) ;
_updateRailNotifs ( ) ;
}
export function clearStreaming ( sessionId ) {
_streamingSessions . delete ( sessionId ) ;
_updateResearchDots ( ) ;
_updateRailNotifs ( ) ;
}
export function markStreamComplete ( sessionId ) {
_researchingSessions . delete ( sessionId ) ;
_streamingSessions . delete ( sessionId ) ;
// Don't pulse if user is already viewing this session — they can see the response
if ( currentSessionId === sessionId ) {
_updateResearchDots ( ) ;
_updateRailNotifs ( ) ;
return ;
}
_completedSessions . add ( sessionId ) ;
_updateResearchDots ( ) ;
_updateRailNotifs ( ) ;
// Show notification dot on Chats section if collapsed
const sessSection = document . getElementById ( 'sessions-section' ) ;
if ( sessSection && sessSection . classList . contains ( 'collapsed' ) ) {
const dot = document . getElementById ( 'chats-notif-dot' ) ;
if ( dot ) dot . style . display = 'inline-block' ;
}
// Safety net: re-apply after a tick in case a concurrent renderSessionList overwrites the DOM
setTimeout ( function ( ) {
if ( _completedSessions . has ( sessionId ) ) {
_updateResearchDots ( ) ;
}
} , 300 ) ;
}
// ── Rail notification dots ──
// Keep rail buttons lit when background work is happening / finished
function _updateRailNotifs ( ) {
// Research rail — pulsing while any session is researching
const railResearch = document . getElementById ( 'rail-research' ) ;
if ( railResearch ) {
// OR in the Deep Research panel's job state (set by panel.js)
// so inline-research and panel-research both keep the rail lit.
const researching = _researchingSessions . size > 0 || ! ! window . _researchJobsActive ;
railResearch . classList . toggle ( 'rail-notify' , researching ) ;
}
// Chats rail — show when a background stream completed
const railChats = document . getElementById ( 'rail-chats' ) ;
if ( railChats ) {
const sidebar = document . getElementById ( 'sidebar' ) ;
const sidebarHidden = sidebar && sidebar . classList . contains ( 'hidden' ) ;
const hasCompleted = _completedSessions . size > 0 ;
railChats . classList . toggle ( 'rail-notify' , hasCompleted && sidebarHidden ) ;
railChats . classList . toggle ( 'rail-notify-success' , hasCompleted && sidebarHidden ) ;
// Store first completed session for click-to-open
if ( hasCompleted ) {
railChats . dataset . targetSession = [ ... _completedSessions ] [ 0 ] ;
} else {
delete railChats . dataset . targetSession ;
}
}
// Trigger rail sync so buttons become visible
if ( window . _syncRailDynamic ) window . _syncRailDynamic ( ) ;
}
/ * *
* Check server for an active stream ( survives page refresh ) .
* If the server is still streaming for this session , show a spinner
* and poll until done , then reload the session .
* /
async function _checkServerStream ( sessionId ) {
try {
// Skip if research is running — it has its own progress UI
if ( _researchingSessions . has ( sessionId ) ) return ;
// Skip if the SSE reader is still actively connected — it handles rendering
if ( window . chatModule && window . chatModule . hasActiveStream && window . chatModule . hasActiveStream ( sessionId ) ) return ;
const res = await fetch ( ` ${ API _BASE } /api/chat/stream_status/ ${ sessionId } ` ) ;
if ( ! res . ok ) return ; // 404 = no active stream
const info = await res . json ( ) ;
if ( info . status !== 'streaming' ) return ;
// Skip if this is a research stream — research has its own progress UI
if ( info . mode === 'research' || info . is _research ) return ;
fix: live-resume chat stream on session re-entry (#2539) (#2561)
* fix: live-resume chat stream on session re-entry (#2539)
When a session was re-entered after a page refresh or in a new tab while
its agent run was still streaming, the UI showed a frozen "Generating
response..." spinner, polled stream_status until the run finished, and
then did a full reload. The live tokens were never shown.
Add resumeStream() in chat.js: it consumes GET /api/chat/resume/{id}
(which replays the run's buffer then streams live), renders reply tokens
as they arrive, and reloads the session on completion for the canonical
final render. sessions.js _checkServerStream now calls it on re-entry and
falls back to the previous spinner+poll path if it is unavailable.
* Finalize plain-text resume in place instead of reloading
On stream completion, resumeStream() called selectSession(), forcing a full
history re-fetch and a visible flicker right as the stream finished.
For plain text replies (no tool calls, sources, doc streaming, or multi-round
output) the live tokens are already rendered, so finalize in place: replace the
live bubble with a canonical single message via chatRenderer.addMessage (markdown
+ footer actions + metrics, the same renderer history uses), captured from the
streamed metrics event. No history refetch, no extra round-trip, no flicker.
Rich responses still reload, since their canonical render (tool bubbles, sources,
multi-bubble) is rebuilt from the saved DB record.
* Use a dedicated set for the resume re-attach lock; fix stale docblock
resumeStream() marked its re-attach lock in _backgroundStreams, which
checkBackgroundStream() also reads. On a second re-entry of the same session
while a resume was still live, checkBackgroundStream() mistook that entry for a
same-tab POST stream and spawned its own spinner+poll bubble. Move the lock to a
dedicated _resumingStreams set (also covered by hasActiveStream) so the two paths
no longer collide. Also update the resumeStream docblock to describe the
in-place finalize vs reload split.
2026-06-04 17:56:15 +02:00
// Live-resume the detached run: replay its buffer then stream live tokens
// (#2539). Falls back to the spinner+poll path below if unavailable.
if ( window . chatModule && window . chatModule . resumeStream ) {
const attached = await window . chatModule . resumeStream ( sessionId ) ;
if ( attached ) return ;
}
// Fallback: server is still streaming, show spinner and poll.
2026-05-31 23:58:26 +09:00
const box = document . getElementById ( 'chat-history' ) ;
if ( ! box ) return ;
const holder = document . createElement ( 'div' ) ;
holder . className = 'msg msg-ai' ;
holder . innerHTML = '<div class="body"></div>' ;
const bodyDiv = holder . querySelector ( '.body' ) ;
const spinnerMod = await import ( './spinner.js' ) ;
const spinner = spinnerMod . default . create ( 'Generating response...' , 'right' ) ;
bodyDiv . appendChild ( spinner . createElement ( ) ) ;
spinner . start ( ) ;
box . appendChild ( holder ) ;
uiModule . scrollHistory ( ) ;
2026-06-06 11:36:30 +02:00
// sessions.js executes before chat.js in module order, so window.chatModule
// may not be set yet when _checkServerStream first runs. Retry resumeStream
// on the first poll tick where it becomes available.
let _resumeRetried = false ;
2026-05-31 23:58:26 +09:00
const pollId = setInterval ( async ( ) => {
if ( getCurrentSessionId ( ) !== sessionId ) {
clearInterval ( pollId ) ;
spinner . destroy ( ) ;
if ( holder . parentNode ) holder . remove ( ) ;
return ;
}
2026-06-06 11:36:30 +02:00
if ( ! _resumeRetried && window . chatModule && window . chatModule . resumeStream ) {
_resumeRetried = true ;
const attached = await window . chatModule . resumeStream ( sessionId ) ;
if ( attached ) {
clearInterval ( pollId ) ;
spinner . destroy ( ) ;
if ( holder . parentNode ) holder . remove ( ) ;
return ;
}
}
2026-05-31 23:58:26 +09:00
try {
const r = await fetch ( ` ${ API _BASE } /api/chat/stream_status/ ${ sessionId } ` ) ;
if ( ! r . ok || ( await r . json ( ) ) . status !== 'streaming' ) {
clearInterval ( pollId ) ;
spinner . destroy ( ) ;
if ( holder . parentNode ) holder . remove ( ) ;
// Reload session to show the completed response + docs
selectSession ( sessionId ) ;
}
} catch ( _ ) {
clearInterval ( pollId ) ;
spinner . destroy ( ) ;
if ( holder . parentNode ) holder . remove ( ) ;
selectSession ( sessionId ) ;
}
} , 1500 ) ;
} catch ( _ ) {
// No stream active — nothing to do
}
}
export function clearStreamComplete ( sessionId ) {
_completedSessions . delete ( sessionId ) ;
// Direct DOM cleanup in case _updateResearchDots misses it
var item = document . querySelector ( ` .list-item[data-session-id=" ${ sessionId } "] ` ) ;
if ( item ) item . classList . remove ( 'stream-complete' ) ;
var star = document . querySelector ( ` .session-star[data-session-id=" ${ sessionId } "] ` ) ;
if ( star ) { star . classList . remove ( 'notify' , 'processing' ) ; star . style . opacity = '' ; }
_updateResearchDots ( ) ;
_updateRailNotifs ( ) ;
}
// Initialize dropdowns once DOM is ready
function _initAllDropdowns ( ) {
initModelPicker ( {
getCurrentSessionId : ( ) => currentSessionId ,
getSessions : ( ) => sessions ,
getPendingChat : ( ) => _pendingChat ,
setPendingChat : ( v ) => { _pendingChat = v ; } ,
createDirectChat ,
} ) ;
_initDropdownDismiss ( ) ;
_initBulkSelect ( ) ;
}
if ( document . readyState === 'loading' ) {
document . addEventListener ( 'DOMContentLoaded' , _initAllDropdowns ) ;
} else {
_initAllDropdowns ( ) ;
}
// Shared global listener to close all session dropdowns on click-away or Escape
function _initDropdownDismiss ( ) {
document . addEventListener ( 'click' , ( e ) => {
2026-06-10 22:39:23 +09:00
if ( e . target . closest ( '.session-dropdown-menu, .session-folder-submenu' ) ) return ;
document . querySelectorAll ( '.session-dropdown-menu, .session-folder-submenu' ) . forEach ( d => d . style . display = 'none' ) ;
2026-05-31 23:58:26 +09:00
} ) ;
// Watch the sidebar — when it's hidden (any path: hamburger, swipe, mobile
// collapse), close any open session dropdowns so they don't orphan over
// the page.
const _sb = document . getElementById ( 'sidebar' ) ;
if ( _sb ) {
new MutationObserver ( ( ) => {
if ( _sb . classList . contains ( 'hidden' ) ) {
2026-06-10 22:39:23 +09:00
document . querySelectorAll ( '.session-dropdown-menu, .session-folder-submenu' ) . forEach ( d => d . style . display = 'none' ) ;
2026-05-31 23:58:26 +09:00
}
} ) . observe ( _sb , { attributes : true , attributeFilter : [ 'class' ] } ) ;
}
document . addEventListener ( 'keydown' , ( e ) => {
2026-06-10 22:39:23 +09:00
if ( e . key !== 'Escape' ) return ;
// Esc must dismiss both the parent dropdown AND the Move-to-folder
// submenu in one keypress — previously only the dropdown closed and
// the submenu was left orphaned on screen.
document . querySelectorAll ( '.session-dropdown-menu, .session-folder-submenu' ) . forEach ( d => d . style . display = 'none' ) ;
2026-05-31 23:58:26 +09:00
} ) ;
}
// ──────────────────────────────────────────────
// Shared: positioned dropdown menu
// ──────────────────────────────────────────────
/ * *
* Show a dropdown menu anchored to a button , using the existing
* . dropdown / . dropdown - item - compact / . session - dropdown - menu CSS .
* Items : [ { label , action , danger ? } ]
* Returns a close ( ) function .
* /
function _showDropdown ( anchorEl , items ) {
// Close any open archive dropdown
document . querySelectorAll ( '.session-dropdown-menu.archive-dd' ) . forEach ( d => d . remove ( ) ) ;
const dd = document . createElement ( 'div' ) ;
dd . className = 'dropdown session-dropdown-menu archive-dd' ;
for ( const item of items ) {
const row = document . createElement ( 'div' ) ;
row . className = 'dropdown-item-compact' + ( item . danger ? ' dropdown-item-danger' : '' ) ;
row . innerHTML = '<span>' + item . label + '</span>' ;
row . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
close ( ) ;
item . action ( ) ;
} ) ;
dd . appendChild ( row ) ;
}
document . body . appendChild ( dd ) ;
// Position using viewport coords (same pattern as session menus)
const rect = anchorEl . getBoundingClientRect ( ) ;
dd . style . right = ( window . innerWidth - rect . right ) + 'px' ;
dd . style . top = '-9999px' ;
dd . style . display = 'block' ;
const ddRect = dd . getBoundingClientRect ( ) ;
if ( rect . bottom + 2 + ddRect . height > window . innerHeight ) {
dd . style . top = Math . max ( 2 , rect . top - ddRect . height - 2 ) + 'px' ;
} else {
dd . style . top = ( rect . bottom + 2 ) + 'px' ;
}
function close ( ) { dd . remove ( ) ; }
// Existing _initDropdownDismiss handles click-away + Escape for .session-dropdown-menu
return close ;
}
// ──────────────────────────────────────────────
// Archive Browser
// ──────────────────────────────────────────────
// All mutable archive state lives here; reset on each openArchive().
const _arc = { data : [ ] , total : 0 , search : '' , offset : 0 , sort : 'recent' , model : '' , debounce : null , selectMode : false , selected : new Set ( ) , allModelCounts : null } ;
function _arcRelativeTime ( 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 ( ) ;
}
// ── Actions (pure side-effects, no DOM creation) ──
// Peek at an archived session — load its history without unarchiving
let _peekingSessionId = null ;
async function _arcPeekOpen ( sid ) {
try {
_peekingSessionId = sid ;
closeArchive ( ) ;
// Load history directly without unarchiving
const res = await fetch ( ` ${ API _BASE } /api/history/ ${ sid } ` ) ;
const data = await res . json ( ) ;
const history = data . history || [ ] ;
// Set as current session so chat renders
currentSessionId = sid ;
// Find the archived session metadata
const meta = _arc . data . find ( s => s . id === sid ) ;
const metaEl = document . getElementById ( 'current-meta' ) ;
if ( metaEl ) metaEl . textContent = ( meta ? . name || 'Archived' ) + ' (archived)' ;
// Render the chat history
const chatBox = document . getElementById ( 'chat-history' ) ;
if ( chatBox ) chatBox . innerHTML = '' ;
if ( window . chatModule && window . chatModule . hideWelcomeScreen ) window . chatModule . hideWelcomeScreen ( ) ;
const addMsg = window . chatModule && window . chatModule . addMessage ;
if ( addMsg ) {
for ( const msg of history ) {
if ( msg . role === 'user' || msg . role === 'assistant' ) {
const model = String ( ( msg . metadata && msg . metadata . model ) || '' ) ;
const content = typeof msg . content === 'string' ? msg . content : ( Array . isArray ( msg . content ) ? msg . content : String ( msg . content || '' ) ) ;
try { addMsg ( msg . role , content , model , msg . metadata || null ) ; } catch ( e ) { console . warn ( 'Failed to render message:' , e ) ; }
}
}
}
if ( window . uiModule ) window . uiModule . scrollHistory ( ) ;
} catch ( e ) {
console . error ( 'Peek open failed:' , e ) ;
uiModule . showError ( 'Failed to open archived session' ) ;
}
}
// When navigating away from a peeked session, just clear the state
function _checkPeekCleanup ( newSessionId ) {
if ( _peekingSessionId && _peekingSessionId !== newSessionId ) {
_peekingSessionId = null ;
}
}
async function _arcRestore ( sid ) {
try {
const res = await fetch ( ` ${ API _BASE } /api/session/ ${ sid } /unarchive ` , { method : 'POST' } ) ;
if ( ! res . ok ) throw new Error ( 'Failed' ) ;
_arcRemove ( sid ) ;
_arcRefreshUI ( ) ;
uiModule . showToast ( 'Session restored' ) ;
loadSessions ( ) ;
} catch { uiModule . showError ( 'Failed to restore session' ) ; }
}
async function _arcDelete ( sid ) {
if ( ! await window . styledConfirm ( 'Delete this session permanently?' , { confirmText : 'Delete' , danger : true } ) ) return ;
try {
const res = await fetch ( ` ${ API _BASE } /api/session/ ${ sid } ` , { method : 'DELETE' } ) ;
if ( ! res . ok ) throw new Error ( 'Failed' ) ;
await _animateSessionRowsRemoving ( [ sid ] , '#archive-grid .archive-row[data-session-id]' ) ;
_arcRemove ( sid ) ;
_arcRefreshUI ( ) ;
uiModule . showToast ( 'Session deleted' ) ;
} catch { uiModule . showError ( 'Failed to delete session' ) ; }
}
function _arcRemove ( sid ) {
_arc . data = _arc . data . filter ( x => x . id !== sid ) ;
_arc . total -- ;
_arc . selected . delete ( sid ) ;
}
async function _arcBulkRestore ( ) {
const ids = [ ... _arc . selected ] ;
if ( ! ids . length ) return ;
for ( const sid of ids ) {
try {
await fetch ( ` ${ API _BASE } /api/session/ ${ sid } /unarchive ` , { method : 'POST' } ) ;
_arcRemove ( sid ) ;
} catch { }
}
_arc . selected . clear ( ) ;
_arcRefreshUI ( ) ;
uiModule . showToast ( ` ${ ids . length } session ${ ids . length > 1 ? 's' : '' } restored ` ) ;
loadSessions ( ) ;
}
async function _arcBulkDelete ( ) {
const ids = [ ... _arc . selected ] ;
if ( ! ids . length ) return ;
const ok = await uiModule . styledConfirm ( ` Delete ${ ids . length } session ${ ids . length > 1 ? 's' : '' } permanently? ` , { confirmText : 'Delete' , danger : true } ) ;
if ( ! ok ) return ;
const deletedIds = [ ] ;
for ( const sid of ids ) {
try {
const res = await fetch ( ` ${ API _BASE } /api/session/ ${ sid } ` , { method : 'DELETE' } ) ;
if ( res . ok ) {
deletedIds . push ( sid ) ;
_arcRemove ( sid ) ;
}
} catch { }
}
await _animateSessionRowsRemoving ( deletedIds , '#archive-grid .archive-row[data-session-id]' ) ;
_arc . selected . clear ( ) ;
_arcRefreshUI ( ) ;
uiModule . showToast ( ` ${ deletedIds . length } session ${ deletedIds . length > 1 ? 's' : '' } deleted ` ) ;
}
function _arcToggleSelectMode ( ) {
_arc . selectMode = ! _arc . selectMode ;
_arc . selected . clear ( ) ;
_arcRefreshUI ( ) ;
}
function _arcUpdateBulkBar ( ) {
const bar = document . getElementById ( 'archive-bulk-bar' ) ;
const count = document . getElementById ( 'archive-selected-count' ) ;
const selectBtn = document . getElementById ( 'archive-select-btn' ) ;
if ( bar ) bar . classList . toggle ( 'hidden' , ! _arc . selectMode ) ;
if ( count ) count . textContent = ` ${ _arc . selected . size } selected ` ;
if ( selectBtn ) {
selectBtn . textContent = _arc . selectMode ? 'Cancel' : 'Select' ;
selectBtn . classList . toggle ( 'active' , _arc . selectMode ) ;
}
}
// ── Data fetching ──
async function _arcFetch ( append ) {
if ( ! append ) _arc . offset = 0 ;
const params = new URLSearchParams ( { offset : String ( _arc . offset ) , limit : '20' , sort : _arc . sort } ) ;
if ( _arc . search ) params . set ( 'search' , _arc . search ) ;
if ( _arc . model ) params . set ( 'model' , _arc . model ) ;
try {
const res = await fetch ( ` ${ API _BASE } /api/sessions/archived? ${ params } ` ) ;
if ( ! res . ok ) throw new Error ( res . statusText ) ;
const data = await res . json ( ) ;
_arc . data = append ? _arc . data . concat ( data . sessions ) : data . sessions ;
_arc . total = data . total ;
// Cache model counts from unfiltered first fetch
if ( ! _arc . allModelCounts && ! _arc . model && ! _arc . search ) {
const counts = { } ;
_arc . data . forEach ( s => {
const m = ( s . model || '' ) . split ( '/' ) . pop ( ) ;
if ( m ) counts [ m ] = ( counts [ m ] || 0 ) + 1 ;
} ) ;
_arc . allModelCounts = { counts , total : _arc . total } ;
}
_arcRefreshUI ( ) ;
} catch ( e ) {
console . error ( 'Archive fetch failed:' , e ) ;
}
}
// ── Rendering (dumb — reads _arc, writes DOM) ──
function _arcRefreshUI ( ) {
_arcRenderStats ( ) ;
_arcRenderChips ( ) ;
_arcRenderGrid ( ) ;
_arcRenderLoadMore ( ) ;
_arcUpdateBulkBar ( ) ;
}
function _arcRenderStats ( ) {
const el = document . getElementById ( 'archive-stats' ) ;
if ( el ) el . textContent = _arc . total ? ` ${ _arc . total } ` : '' ;
}
function _arcRenderChips ( ) {
const el = document . getElementById ( 'archive-chips' ) ;
if ( ! el ) return ;
// Use cached counts so chips don't disappear when filtering
const cached = _arc . allModelCounts ;
if ( ! cached ) return ;
const modelCounts = cached . counts ;
const models = Object . keys ( modelCounts ) . sort ( ) ;
if ( models . length < 2 ) { el . innerHTML = '' ; return ; }
el . innerHTML = '' ;
const mkChip = ( label , value , count ) => {
const chip = document . createElement ( 'button' ) ;
chip . className = 'doclib-chip' + ( _arc . model === value ? ' active' : '' ) ;
chip . textContent = ` ${ label } ( ${ count } ) ` ;
chip . addEventListener ( 'click' , ( ) => { _arc . model = ( _arc . model === value ? '' : value ) ; _arcFetch ( false ) ; } ) ;
el . appendChild ( chip ) ;
} ;
mkChip ( 'All' , '' , cached . total ) ;
models . forEach ( m => mkChip ( m , m , modelCounts [ m ] ) ) ;
}
function _arcRenderCard ( s ) {
const card = document . createElement ( 'div' ) ;
card . className = 'memory-item archive-row' + ( _arc . selected . has ( s . id ) ? ' selected' : '' ) ;
card . dataset . sessionId = s . id ;
const modelShort = uiModule . esc ( ( s . model || '' ) . split ( '/' ) . pop ( ) ) ;
const msgCount = s . message _count || 0 ;
const checkboxHtml = _arc . selectMode
? ` <input type="checkbox" class="memory-select-cb archive-checkbox" data-sid=" ${ s . id } " ${ _arc . selected . has ( s . id ) ? 'checked' : '' } > `
: '' ;
card . innerHTML = `
$ { checkboxHtml }
< div style = "flex:1;min-width:0;" >
< div class = "memory-item-title" > $ { uiModule . esc ( s . name || 'Untitled' ) } < / d i v >
< div class = "memory-item-meta" style = "font-size:10px;opacity:0.4;margin-top:2px;" >
< span > $ { modelShort || 'no model' } < / s p a n >
< span > \u00b7 < / s p a n >
< span > $ { msgCount } msg$ { msgCount !== 1 ? 's' : '' } < / s p a n >
< span > \u00b7 < / s p a n >
< span > $ { _arcRelativeTime ( s . updated _at ) } < / s p a n >
< / d i v >
< / d i v >
< div class = "memory-item-actions" >
< button class = "memory-item-btn archive-menu-btn" 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" / > < / s v g > < / b u t t o n >
< / d i v >
` ;
const checkbox = card . querySelector ( '.archive-checkbox' ) ;
if ( checkbox ) {
checkbox . addEventListener ( 'change' , ( e ) => {
e . stopPropagation ( ) ;
if ( e . target . checked ) _arc . selected . add ( s . id ) ;
else _arc . selected . delete ( s . id ) ;
card . classList . toggle ( 'selected' , e . target . checked ) ;
_arcUpdateBulkBar ( ) ;
} ) ;
}
card . querySelector ( '.archive-menu-btn' ) . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
_showDropdown ( e . currentTarget , [
{ label : 'Open' , action : ( ) => _arcPeekOpen ( s . id ) } ,
{ label : 'Restore' , action : ( ) => _arcRestore ( s . id ) } ,
{ label : 'Delete' , action : ( ) => _arcDelete ( s . id ) , danger : true } ,
] ) ;
} ) ;
card . addEventListener ( 'click' , ( ) => {
if ( _arc . selectMode ) {
if ( _arc . selected . has ( s . id ) ) _arc . selected . delete ( s . id ) ;
else _arc . selected . add ( s . id ) ;
const cb = card . querySelector ( '.archive-checkbox' ) ;
if ( cb ) cb . checked = _arc . selected . has ( s . id ) ;
card . classList . toggle ( 'selected' , _arc . selected . has ( s . id ) ) ;
_arcUpdateBulkBar ( ) ;
} else {
_arcPeekOpen ( s . id ) ;
}
} ) ;
return card ;
}
function _arcRenderGrid ( ) {
const grid = document . getElementById ( 'archive-grid' ) ;
if ( ! grid ) return ;
if ( _arc . data . length === 0 ) {
grid . innerHTML = '<div class="doclib-empty">No archived sessions</div>' ;
return ;
}
grid . innerHTML = '' ;
for ( const s of _arc . data ) grid . appendChild ( _arcRenderCard ( s ) ) ;
}
function _arcRenderLoadMore ( ) {
const btn = document . getElementById ( 'archive-load-more' ) ;
if ( ! btn ) return ;
btn . style . display = _arc . data . length < _arc . total ? '' : 'none' ;
}
// ── Unified Library Modal (Chats / Documents / Archive) ──
const _lib = { tab : 'chats' , search : '' , sort : 'recent' , debounce : null , selectMode : false , selected : new Set ( ) } ;
export function openLibrary ( defaultTab ) {
// Delegate everything to the document module's library (has tabs for Chats/Documents/Archive)
if ( window . documentModule && window . documentModule . openLibrary ) {
window . documentModule . openLibrary ( { tab : defaultTab || 'documents' } ) ;
return ;
}
if ( document . getElementById ( 'library-modal' ) ) return ;
Object . assign ( _lib , { tab : defaultTab || 'chats' , search : '' , sort : 'recent' , debounce : null , selectMode : false , selected : new Set ( ) } ) ;
const modal = document . createElement ( 'div' ) ;
modal . className = 'modal' ;
modal . id = 'library-modal' ;
modal . innerHTML = `
< div class = "modal-content doclib-modal-content" >
< div class = "modal-header" >
< h4 > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" style = "vertical-align:-2px;margin-right:6px" > < 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" / > < / s v g > L i b r a r y < s p a n i d = " l i b - s t a t s " s t y l e = " f o n t - s i z e : 0 . 8 e m ; o p a c i t y : 0 . 5 ; f o n t - w e i g h t : n o r m a l ; m a r g i n - l e f t : 4 p x " > < / s p a n > < / h 4 >
< button class = "close-btn" id = "lib-close" > ✖ < / b u t t o n >
< / d i v >
< div class = "modal-body" >
< div class = "lib-tabs" id = "lib-tabs" >
< button class = "lib-tab${_lib.tab === 'chats' ? ' active' : ''}" data - lib - tab = "chats" > Chats < / b u t t o n >
< button class = "lib-tab${_lib.tab === 'documents' ? ' active' : ''}" data - lib - tab = "documents" > Documents < / b u t t o n >
< button class = "lib-tab${_lib.tab === 'archive' ? ' active' : ''}" data - lib - tab = "archive" > Archive < / b u t t o n >
< button class = "lib-tab${_lib.tab === 'research' ? ' active' : ''}" data - lib - tab = "research" > Research < / b u t t o n >
< / d i v >
< div style = "display:flex;align-items:center;gap:6px;margin-bottom:6px;" >
< select class = "memory-sort-select" id = "lib-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 >
< input type = "text" class = "memory-search-input" id = "lib-search" placeholder = "Filter\u2026" style = "flex:1;" / >
< button class = "memory-toolbar-btn" id = "lib-select-btn" title = "Select" > Select < / b u t t o n >
< / d i v >
< div class = "memory-bulk-bar hidden" id = "lib-bulk-bar" >
< label class = "memory-bulk-check-all" > < input type = "checkbox" id = "lib-select-all" > All < / l a b e l >
< span id = "lib-selected-count" style = "color:color-mix(in srgb, var(--fg) 50%, transparent);font-size:10px;flex:1;" > 0 selected < / s p a n >
< button class = "memory-toolbar-btn" id = "lib-bulk-action1" > < / b u t t o n >
< button class = "memory-toolbar-btn danger" id = "lib-bulk-delete" > Delete < / b u t t o n >
< / d i v >
< div class = "doclib-grid archive-list" id = "lib-grid" > < / d i v >
< / d i v >
< / d i v >
` ;
document . body . appendChild ( modal ) ;
// Draggable
const _clContent = modal . querySelector ( '.modal-content' ) ;
const _clHeader = modal . querySelector ( '.modal-header' ) ;
if ( themeModule && themeModule . makeDraggable && _clContent && _clHeader ) {
themeModule . makeDraggable ( _clContent , _clHeader ) ;
}
document . getElementById ( 'lib-close' ) . addEventListener ( 'click' , closeLibrary ) ;
// Tab switching
modal . querySelectorAll ( '.lib-tab' ) . forEach ( tab => {
tab . addEventListener ( 'click' , ( ) => {
// Documents tab — open the document module's library (has expand/preview)
if ( tab . dataset . libTab === 'documents' && window . documentModule && window . documentModule . openLibrary ) {
closeLibrary ( ) ;
window . documentModule . openLibrary ( ) ;
return ;
}
_lib . tab = tab . dataset . libTab ;
_lib . search = '' ;
_lib . selectMode = false ;
_lib . selected . clear ( ) ;
modal . querySelectorAll ( '.lib-tab' ) . forEach ( t => t . classList . remove ( 'active' ) ) ;
tab . classList . add ( 'active' ) ;
document . getElementById ( 'lib-search' ) . value = '' ;
document . getElementById ( 'lib-bulk-bar' ) . classList . add ( 'hidden' ) ;
// Update bulk action button label based on tab
const action1 = document . getElementById ( 'lib-bulk-action1' ) ;
if ( _lib . tab === 'archive' ) { action1 . textContent = 'Restore' ; }
else if ( _lib . tab === 'chats' ) { action1 . textContent = 'Archive' ; }
else if ( _lib . tab === 'research' ) { action1 . textContent = 'Open Report' ; }
else { action1 . textContent = 'Export' ; }
_renderLibGrid ( ) ;
} ) ;
} ) ;
// Set initial bulk action label
const _initAction = document . getElementById ( 'lib-bulk-action1' ) ;
if ( _initAction ) _initAction . textContent = _lib . tab === 'archive' ? 'Restore' : _lib . tab === 'documents' ? 'Export' : 'Archive' ;
document . getElementById ( 'lib-sort' ) . addEventListener ( 'change' , ( ) => { _lib . sort = document . getElementById ( 'lib-sort' ) . value ; _renderLibGrid ( ) ; } ) ;
document . getElementById ( 'lib-search' ) . addEventListener ( 'input' , ( e ) => {
clearTimeout ( _lib . debounce ) ;
_lib . debounce = setTimeout ( ( ) => { _lib . search = e . target . value . trim ( ) . toLowerCase ( ) ; _renderLibGrid ( ) ; } , 200 ) ;
} ) ;
// Select mode
document . getElementById ( 'lib-select-btn' ) . addEventListener ( 'click' , ( ) => {
_lib . selectMode = ! _lib . selectMode ;
_lib . selected . clear ( ) ;
document . getElementById ( 'lib-bulk-bar' ) . classList . toggle ( 'hidden' , ! _lib . selectMode ) ;
_renderLibGrid ( ) ;
} ) ;
document . getElementById ( 'lib-select-all' ) . addEventListener ( 'change' , ( e ) => {
const checked = e . target . checked ;
document . querySelectorAll ( '#lib-grid .memory-select-cb' ) . forEach ( cb => { cb . checked = checked ; } ) ;
document . querySelectorAll ( '#lib-grid .doclib-card' ) . forEach ( card => {
const id = card . dataset . sessionId || card . dataset . docId ;
if ( id ) { if ( checked ) _lib . selected . add ( id ) ; else _lib . selected . delete ( id ) ; }
} ) ;
_updateLibCount ( ) ;
} ) ;
// Bulk action 1 (Archive/Restore/Export)
document . getElementById ( 'lib-bulk-action1' ) . addEventListener ( 'click' , async ( ) => {
if ( _lib . tab === 'chats' ) {
for ( const sid of _lib . selected ) await fetch ( ` ${ API _BASE } /api/session/ ${ sid } /archive ` , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } } ) ;
uiModule . showToast ( ` Archived ${ _lib . selected . size } sessions ` ) ;
} else if ( _lib . tab === 'archive' ) {
for ( const sid of _lib . selected ) await fetch ( ` ${ API _BASE } /api/session/ ${ sid } /restore ` , { method : 'POST' } ) ;
uiModule . showToast ( ` Restored ${ _lib . selected . size } sessions ` ) ;
}
_lib . selected . clear ( ) ;
_lib . selectMode = false ;
document . getElementById ( 'lib-bulk-bar' ) . classList . add ( 'hidden' ) ;
await loadSessions ( ) ;
_renderLibGrid ( ) ;
} ) ;
// Bulk delete
document . getElementById ( 'lib-bulk-delete' ) . addEventListener ( 'click' , async ( ) => {
if ( ! await uiModule . styledConfirm ( ` Delete ${ _lib . selected . size } items? ` , { confirmText : 'Delete' , danger : true } ) ) return ;
if ( _lib . tab === 'chats' || _lib . tab === 'archive' ) {
for ( const sid of _lib . selected ) await fetch ( ` ${ API _BASE } /api/session/ ${ sid } ` , { method : 'DELETE' } ) ;
} else if ( _lib . tab === 'documents' ) {
for ( const did of _lib . selected ) await fetch ( ` ${ API _BASE } /api/document/ ${ did } ` , { method : 'DELETE' } ) ;
} else if ( _lib . tab === 'research' ) {
for ( const rid of _lib . selected ) await fetch ( ` ${ API _BASE } /api/research/ ${ rid } ` , { method : 'DELETE' } ) ;
}
_lib . selected . clear ( ) ;
_lib . selectMode = false ;
document . getElementById ( 'lib-bulk-bar' ) . classList . add ( 'hidden' ) ;
await loadSessions ( ) ;
_renderLibGrid ( ) ;
} ) ;
_renderLibGrid ( ) ;
}
function _updateLibCount ( ) {
const el = document . getElementById ( 'lib-selected-count' ) ;
if ( el ) el . textContent = ` ${ _lib . selected . size } selected ` ;
}
function _renderLibGrid ( ) {
const grid = document . getElementById ( 'lib-grid' ) ;
if ( ! grid ) return ;
if ( _lib . tab === 'chats' ) _renderLibChats ( grid ) ;
else if ( _lib . tab === 'archive' ) _renderLibArchive ( grid ) ;
else if ( _lib . tab === 'documents' ) _renderLibDocuments ( grid ) ;
else if ( _lib . tab === 'research' ) _renderLibResearch ( grid ) ;
}
function _renderLibChats ( grid ) {
if ( ! sessions || ! sessions . length ) {
grid . innerHTML = '<div class="doclib-empty">No sessions loaded</div>' ;
return ;
}
let filtered = sessions . filter ( s => ! s . archived ) ;
if ( _lib . search ) {
const q = _lib . search ;
filtered = filtered . filter ( s => ( s . name || '' ) . toLowerCase ( ) . includes ( q ) || ( s . model || '' ) . toLowerCase ( ) . includes ( q ) ) ;
}
if ( _lib . sort === 'oldest' ) filtered . sort ( ( a , b ) => ( a . created _at || '' ) > ( b . created _at || '' ) ? 1 : - 1 ) ;
else if ( _lib . sort === 'most-messages' ) filtered . sort ( ( a , b ) => ( b . message _count || 0 ) - ( a . message _count || 0 ) ) ;
else if ( _lib . sort === '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 ( 'lib-stats' ) ;
if ( stats ) stats . textContent = ` ( ${ filtered . length } ) ` ;
if ( ! filtered . length ) { grid . innerHTML = '<div class="doclib-empty">No chats found</div>' ; return ; }
grid . innerHTML = '' ;
for ( const s of filtered ) {
const card = _buildLibCard ( s . id , s . name || 'Untitled' , s . message _count || 0 , ( s . model || '' ) . split ( '/' ) . pop ( ) , s . updated _at , s . id === currentSessionId ) ;
card . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.archive-menu-btn,.memory-select-cb' ) ) return ;
if ( _lib . selectMode ) { _toggleLibSelect ( card , s . id ) ; return ; }
closeLibrary ( ) ; selectSession ( s . id ) ;
} ) ;
card . querySelector ( '.archive-menu-btn' ) . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
_showDropdown ( e . currentTarget , [
{ label : 'Open' , action : ( ) => { closeLibrary ( ) ; selectSession ( s . id ) ; } } ,
{ label : 'Archive' , action : async ( ) => { await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } /archive ` , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } } ) ; await loadSessions ( ) ; _renderLibGrid ( ) ; } } ,
{ label : 'Delete' , action : async ( ) => { if ( ! await uiModule . styledConfirm ( 'Delete?' , { confirmText : 'Delete' , danger : true } ) ) return ; await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } ` , { method : 'DELETE' } ) ; await loadSessions ( ) ; _renderLibGrid ( ) ; } , danger : true } ,
] ) ;
} ) ;
grid . appendChild ( card ) ;
}
}
async function _renderLibArchive ( grid ) {
grid . innerHTML = '' ;
grid . appendChild ( spinnerModule . createLoadingRow ( 'Loading…' ) ) ;
try {
const params = new URLSearchParams ( { limit : '50' , sort : _lib . sort === 'most-messages' ? 'messages' : _lib . sort } ) ;
if ( _lib . search ) params . set ( 'search' , _lib . search ) ;
const res = await fetch ( ` ${ API _BASE } /api/sessions/archived? ${ params } ` ) ;
const data = await res . json ( ) ;
const items = data . sessions || [ ] ;
const stats = document . getElementById ( 'lib-stats' ) ;
if ( stats ) stats . textContent = ` ( ${ data . total || items . length } ) ` ;
if ( ! items . length ) { grid . innerHTML = '<div class="doclib-empty">No archived sessions</div>' ; return ; }
grid . innerHTML = '' ;
for ( const s of items ) {
const card = _buildLibCard ( s . id , s . name || 'Untitled' , s . message _count || 0 , ( s . model || '' ) . split ( '/' ) . pop ( ) , s . updated _at ) ;
card . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.archive-menu-btn,.memory-select-cb' ) ) return ;
if ( _lib . selectMode ) { _toggleLibSelect ( card , s . id ) ; return ; }
} ) ;
card . querySelector ( '.archive-menu-btn' ) . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
_showDropdown ( e . currentTarget , [
{ label : 'Restore' , action : async ( ) => { await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } /restore ` , { method : 'POST' } ) ; await loadSessions ( ) ; _renderLibGrid ( ) ; } } ,
{ label : 'Delete' , action : async ( ) => { if ( ! await uiModule . styledConfirm ( 'Delete?' , { confirmText : 'Delete' , danger : true } ) ) return ; await fetch ( ` ${ API _BASE } /api/session/ ${ s . id } ` , { method : 'DELETE' } ) ; _renderLibGrid ( ) ; } , danger : true } ,
] ) ;
} ) ;
grid . appendChild ( card ) ;
}
} catch ( e ) { console . error ( 'Library archive error:' , e ) ; grid . innerHTML = '<div class="doclib-empty">Failed to load archive</div>' ; }
}
async function _renderLibDocuments ( grid ) {
grid . innerHTML = '' ;
grid . appendChild ( spinnerModule . createLoadingRow ( 'Loading…' ) ) ;
try {
const params = new URLSearchParams ( { limit : '50' , sort : _lib . sort } ) ;
if ( _lib . search ) params . set ( 'search' , _lib . search ) ;
const res = await fetch ( ` ${ API _BASE } /api/documents/library? ${ params } ` ) ;
const data = await res . json ( ) ;
const docs = data . documents || [ ] ;
const stats = document . getElementById ( 'lib-stats' ) ;
if ( stats ) stats . textContent = ` ( ${ data . total || docs . length } ) ` ;
if ( ! docs . length ) { grid . innerHTML = '<div class="doclib-empty">No documents found</div>' ; return ; }
grid . innerHTML = '' ;
for ( const d of docs ) {
const card = _buildLibCard ( d . id , d . title || 'Untitled' , d . version _count || 1 , d . language || 'text' , d . updated _at , false , true ) ;
card . dataset . docId = d . id ;
card . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.archive-menu-btn,.memory-select-cb' ) ) return ;
if ( _lib . selectMode ) { _toggleLibSelect ( card , d . id ) ; return ; }
// Open document in its session
if ( d . session _id && window . documentModule ) {
closeLibrary ( ) ;
selectSession ( d . session _id ) ;
setTimeout ( ( ) => { if ( window . documentModule . loadSessionDocs ) window . documentModule . loadSessionDocs ( d . session _id ) ; } , 300 ) ;
}
} ) ;
card . querySelector ( '.archive-menu-btn' ) . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
_showDropdown ( e . currentTarget , [
{ label : 'Open' , action : ( ) => { if ( d . session _id ) { closeLibrary ( ) ; selectSession ( d . session _id ) ; } } } ,
{ label : 'Delete' , action : async ( ) => { if ( ! await uiModule . styledConfirm ( 'Delete?' , { confirmText : 'Delete' , danger : true } ) ) return ; await fetch ( ` ${ API _BASE } /api/document/ ${ d . id } ` , { method : 'DELETE' } ) ; _renderLibGrid ( ) ; } , danger : true } ,
] ) ;
} ) ;
grid . appendChild ( card ) ;
}
} catch ( e ) { console . error ( 'Library documents error:' , e ) ; grid . innerHTML = '<div class="doclib-empty">Failed to load documents</div>' ; }
}
async function _renderLibResearch ( grid ) {
grid . innerHTML = '' ;
grid . appendChild ( spinnerModule . createLoadingRow ( 'Loading research…' ) ) ;
try {
const params = new URLSearchParams ( { limit : '50' , sort : _lib . sort } ) ;
if ( _lib . search ) params . set ( 'search' , _lib . search ) ;
const res = await fetch ( ` ${ API _BASE } /api/research/library? ${ params } ` ) ;
if ( ! res . ok ) throw new Error ( res . status ) ;
const data = await res . json ( ) ;
const items = data . research || [ ] ;
const statsEl = document . getElementById ( 'lib-stats' ) ;
if ( statsEl ) statsEl . textContent = ` ${ data . total || 0 } research ` ;
grid . innerHTML = '' ;
if ( ! items . length ) {
grid . innerHTML = '<div class="doclib-empty">No research found</div>' ;
return ;
}
for ( const item of items ) {
const meta = [
item . duration || '' ,
item . rounds ? item . rounds + ' rounds' : '' ,
] . filter ( Boolean ) . join ( ' \u00b7 ' ) ;
const card = _buildLibCard (
item . id , item . query || '(untitled)' , item . source _count || 0 ,
meta , item . completed _at ? new Date ( item . completed _at * 1000 ) . toISOString ( ) : '' ,
false , false ,
) ;
const metaEl = card . querySelector ( '.memory-item-meta' ) ;
if ( metaEl ) metaEl . textContent = metaEl . textContent . replace ( /\d+ msgs?/ , ( item . source _count || 0 ) + ' sources' ) ;
card . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.archive-menu-btn' ) || e . target . closest ( '.memory-select-cb' ) ) return ;
window . open ( ` ${ API _BASE } /api/research/report/ ${ item . id } ` , '_blank' ) ;
} ) ;
const menuBtn = card . querySelector ( '.archive-menu-btn' ) ;
if ( menuBtn ) {
menuBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
_showDropdown ( e . currentTarget , [
{ label : 'Open Report' , action : ( ) => window . open ( ` ${ API _BASE } /api/research/report/ ${ item . id } ` , '_blank' ) } ,
{ label : 'Re-run' , action : ( ) => {
const modal = document . getElementById ( 'library-modal' ) ;
if ( modal ) modal . style . display = 'none' ;
const msgInput = document . getElementById ( 'message' ) ;
if ( msgInput ) { msgInput . value = item . query ; msgInput . focus ( ) ; }
uiModule . showToast ( 'Toggle Research and send to re-run' ) ;
} } ,
{ label : 'Delete' , danger : true , action : async ( ) => {
if ( ! await window . styledConfirm ( 'Delete this research?' , { confirmText : 'Delete' , danger : true } ) ) return ;
await fetch ( ` ${ API _BASE } /api/research/ ${ item . id } ` , { method : 'DELETE' } ) ;
_renderLibGrid ( ) ;
} } ,
] ) ;
} ) ;
}
grid . appendChild ( card ) ;
}
} catch ( e ) { console . error ( 'Library research error:' , e ) ; grid . innerHTML = '<div class="doclib-empty">Failed to load research</div>' ; }
}
function _buildLibCard ( id , title , count , meta , time , isActive , isDoc ) {
const card = document . createElement ( 'div' ) ;
card . className = 'memory-item' ;
card . dataset . sessionId = id ;
if ( isDoc ) card . dataset . docId = id ;
const cbHtml = _lib . selectMode ? ` <input type="checkbox" class="memory-select-cb" ${ _lib . selected . has ( id ) ? ' checked' : '' } > ` : '' ;
const metaParts = [ ] ;
if ( meta ) metaParts . push ( uiModule . esc ( meta ) ) ;
metaParts . push ( isDoc ? 'v' + count : count + ' msg' + ( count !== 1 ? 's' : '' ) ) ;
if ( time ) metaParts . push ( _arcRelativeTime ( time ) ) ;
card . innerHTML = `
$ { cbHtml }
< div style = "flex:1;min-width:0;" >
< div class = "memory-item-title" $ { isActive ? ' style="color:var(--accent);"' : '' } > $ { uiModule . esc ( title ) } < / d i v >
< div class = "memory-item-meta" style = "font-size:10px;opacity:0.4;margin-top:2px;" > $ { metaParts . join ( ' \u00b7 ' ) } < / d i v >
< / d i v >
< div class = "memory-item-actions" >
< button class = "memory-item-btn archive-menu-btn" 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" / > < / s v g > < / b u t t o n >
< / d i v >
` ;
const cb = card . querySelector ( '.memory-select-cb' ) ;
if ( cb ) {
cb . addEventListener ( 'click' , ( e ) => e . stopPropagation ( ) ) ;
cb . addEventListener ( 'change' , ( ) => { if ( cb . checked ) _lib . selected . add ( id ) ; else _lib . selected . delete ( id ) ; _updateLibCount ( ) ; } ) ;
}
return card ;
}
function _toggleLibSelect ( card , id ) {
const cb = card . querySelector ( '.memory-select-cb' ) ;
if ( cb ) { cb . checked = ! cb . checked ; if ( cb . checked ) _lib . selected . add ( id ) ; else _lib . selected . delete ( id ) ; _updateLibCount ( ) ; }
}
export function closeLibrary ( ) {
const modal = document . getElementById ( 'library-modal' ) ;
if ( modal ) {
const content = modal . querySelector ( '.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 ( ) ;
}
}
}
export function openArchive ( ) {
if ( document . getElementById ( 'archive-modal' ) ) return ;
Object . assign ( _arc , { data : [ ] , total : 0 , search : '' , offset : 0 , sort : 'recent' , model : '' , debounce : null , selectMode : false , selected : new Set ( ) , allModelCounts : null } ) ;
const modal = document . createElement ( 'div' ) ;
modal . className = 'modal' ;
modal . id = 'archive-modal' ;
modal . innerHTML = `
< div class = "modal-content doclib-modal-content" >
< div class = "modal-header" >
< h4 > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" style = "vertical-align:-2px;margin-right:6px" > < 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 < s p a n i d = " a r c h i v e - s t a t s " s t y l e = " f o n t - s i z e : 0 . 8 e m ; o p a c i t y : 0 . 5 ; f o n t - w e i g h t : n o r m a l ; m a r g i n - l e f t : 4 p x " > < / s p a n > < / h 4 >
< button class = "close-btn" id = "archive-close" > ✖ < / b u t t o n >
< / d i v >
< div class = "modal-body" >
< div class = "doclib-chips" id = "archive-chips" > < / d i v >
< div style = "display:flex;align-items:center;gap:6px;margin-bottom:6px;" >
< select class = "memory-sort-select" id = "archive-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 >
< input type = "text" class = "memory-search-input" id = "archive-search" placeholder = "Filter\u2026" style = "flex:1;" / >
< button class = "memory-toolbar-btn" id = "archive-select-btn" title = "Select sessions" > Select < / b u t t o n >
< / d i v >
< div class = "memory-bulk-bar hidden" id = "archive-bulk-bar" >
< label class = "memory-bulk-check-all" > < input type = "checkbox" id = "archive-select-all" > All < / l a b e l >
< span id = "archive-selected-count" style = "color:color-mix(in srgb, var(--fg) 50%, transparent);font-size:10px;flex:1;" > 0 selected < / s p a n >
< button class = "memory-toolbar-btn" id = "archive-bulk-restore" > Restore < / b u t t o n >
< button class = "memory-toolbar-btn danger" id = "archive-bulk-delete" > Delete < / b u t t o n >
< / d i v >
< div class = "doclib-grid archive-list" id = "archive-grid" > < / d i v >
< button class = "doclib-load-more" id = "archive-load-more" style = "display:none" > Load more < / b u t t o n >
< / d i v >
< / d i v >
` ;
document . body . appendChild ( modal ) ;
// Make draggable via header
const _arcContent = modal . querySelector ( '.modal-content' ) ;
const _arcHeader = modal . querySelector ( '.modal-header' ) ;
if ( themeModule && themeModule . makeDraggable && _arcContent && _arcHeader ) {
themeModule . makeDraggable ( _arcContent , _arcHeader ) ;
}
document . getElementById ( 'archive-close' ) . addEventListener ( 'click' , closeArchive ) ;
document . getElementById ( 'archive-sort' ) . addEventListener ( 'change' , ( e ) => { _arc . sort = e . target . value ; _arcFetch ( false ) ; } ) ;
document . getElementById ( 'archive-search' ) . addEventListener ( 'input' , ( e ) => {
clearTimeout ( _arc . debounce ) ;
_arc . debounce = setTimeout ( ( ) => { _arc . search = e . target . value . trim ( ) ; _arcFetch ( false ) ; } , 300 ) ;
} ) ;
document . getElementById ( 'archive-load-more' ) . addEventListener ( 'click' , ( ) => { _arc . offset = _arc . data . length ; _arcFetch ( true ) ; } ) ;
document . getElementById ( 'archive-select-btn' ) . addEventListener ( 'click' , _arcToggleSelectMode ) ;
document . getElementById ( 'archive-bulk-restore' ) . addEventListener ( 'click' , _arcBulkRestore ) ;
document . getElementById ( 'archive-bulk-delete' ) . addEventListener ( 'click' , _arcBulkDelete ) ;
document . getElementById ( 'archive-select-all' ) . addEventListener ( 'change' , ( e ) => {
if ( e . target . checked ) _arc . data . forEach ( s => _arc . selected . add ( s . id ) ) ;
else _arc . selected . clear ( ) ;
_arcRefreshUI ( ) ;
} ) ;
modal . addEventListener ( 'click' , ( e ) => { if ( uiModule . isTouchInsideModal ( ) ) return ; if ( e . target === modal ) closeArchive ( ) ; } ) ;
_arcFetch ( false ) ;
}
export function closeArchive ( ) {
const modal = document . getElementById ( 'archive-modal' ) ;
if ( modal ) {
const content = modal . querySelector ( '.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 ( ) ;
}
}
}
/** Update has_documents flag for a session and re-render the sidebar icon */
export function getSortMode ( ) { return _sortMode ; }
export function setSortMode ( mode ) {
_sortMode = mode || null ;
if ( mode ) Storage . set ( 'odysseus-session-sort' , mode ) ;
else Storage . remove ( 'odysseus-session-sort' ) ;
renderSessionList ( ) ;
}
export function setSessionHasDocs ( sessionId , hasDocs ) {
const s = sessions . find ( s => s . id === sessionId ) ;
if ( s && s . has _documents !== hasDocs ) {
s . has _documents = hasDocs ;
renderSessionList ( ) ;
}
}
// Export all functions to window for use in main app
const sessionModule = {
initDependencies ,
renderSessionList ,
loadSessions ,
selectSession ,
createDirectChat ,
materializePendingSession ,
hasPendingChat ,
getPendingChat ,
getCurrentSessionId ,
getSessions ,
getCurrentModel ,
getCurrentEndpointUrl ,
setCurrentSessionId ,
initDragSort ,
updateModelPicker ,
markResearching ,
clearResearching ,
markStreaming ,
clearStreaming ,
markStreamComplete ,
clearStreamComplete ,
openLibrary ,
closeLibrary ,
openArchive ,
closeArchive ,
setSessionHasDocs ,
getSortMode ,
setSortMode
} ;
export { updateModelPicker } ;
export default sessionModule ;