// ── Session action icons (SVG, monochrome, inherit currentColor) ── const ICONS={ pin:'', unpin:'', folder:'', archive:'', unarchive:'', dup:'', trash:'', more:'', }; let _sessionActionMenu = null; let _sessionActionAnchor = null; let _sessionActionSessionId = null; function closeSessionActionMenu(){ if(_sessionActionMenu){ _sessionActionMenu.remove(); _sessionActionMenu = null; } if(_sessionActionAnchor){ _sessionActionAnchor.classList.remove('active'); const row=_sessionActionAnchor.closest('.session-item'); if(row) row.classList.remove('menu-open'); _sessionActionAnchor = null; } _sessionActionSessionId = null; } function _positionSessionActionMenu(anchorEl){ if(!_sessionActionMenu || !anchorEl) return; const rect=anchorEl.getBoundingClientRect(); const menuW=Math.min(280, Math.max(220, _sessionActionMenu.scrollWidth || 220)); let left=rect.right-menuW; if(left<8) left=8; if(left+menuW>window.innerWidth-8) left=window.innerWidth-menuW-8; _sessionActionMenu.style.left=left+'px'; _sessionActionMenu.style.top='8px'; const menuH=_sessionActionMenu.offsetHeight || 0; let top=rect.bottom+6; if(top+menuH>window.innerHeight-8 && rect.top>menuH+12){ top=rect.top-menuH-6; } if(top<8) top=8; _sessionActionMenu.style.top=top+'px'; } function _buildSessionAction(label, meta, icon, onSelect, extraClass=''){ const opt=document.createElement('button'); opt.type='button'; opt.className='ws-opt session-action-opt'+(extraClass?` ${extraClass}`:''); opt.innerHTML= `` + `${icon}` + `` + `${esc(label)}` + (meta?`${esc(meta)}`:'') + `` + ``; opt.onclick=async(e)=>{ e.preventDefault(); e.stopPropagation(); await onSelect(); }; return opt; } function _openSessionActionMenu(session, anchorEl){ if(_sessionActionMenu && _sessionActionSessionId===session.session_id && _sessionActionAnchor===anchorEl){ closeSessionActionMenu(); return; } closeSessionActionMenu(); const menu=document.createElement('div'); menu.className='session-action-menu open'; menu.appendChild(_buildSessionAction( session.pinned?'Unpin conversation':'Pin conversation', session.pinned?'Remove from the pinned section':'Keep this conversation at the top', session.pinned?ICONS.pin:ICONS.unpin, async()=>{ closeSessionActionMenu(); const newPinned=!session.pinned; try{ await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})}); session.pinned=newPinned; if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned; renderSessionList(); }catch(err){showToast('Pin failed: '+err.message);} }, session.pinned?'is-active':'' )); menu.appendChild(_buildSessionAction( 'Move to project', session.project_id?'Change which project this conversation belongs to':'Assign this conversation to a project', ICONS.folder, async()=>{ closeSessionActionMenu(); _showProjectPicker(session, anchorEl); } )); menu.appendChild(_buildSessionAction( session.archived?'Restore conversation':'Archive conversation', session.archived?'Bring this conversation back into the main list':'Hide this conversation until archived is shown', session.archived?ICONS.unarchive:ICONS.archive, async()=>{ closeSessionActionMenu(); try{ await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})}); session.archived=!session.archived; if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived; await renderSessionList(); showToast(session.archived?'Session archived':'Session restored'); }catch(err){showToast('Archive failed: '+err.message);} } )); menu.appendChild(_buildSessionAction( 'Duplicate conversation', 'Create a copy with the same workspace and model', ICONS.dup, async()=>{ closeSessionActionMenu(); try{ const res=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:session.workspace,model:session.model})}); if(res.session){ await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:res.session.session_id,title:(session.title||'Untitled')+' (copy)'})}); await loadSession(res.session.session_id); await renderSessionList(); showToast('Session duplicated'); } }catch(err){showToast('Duplicate failed: '+err.message);} } )); menu.appendChild(_buildSessionAction( 'Delete conversation', 'Permanently remove this conversation', ICONS.trash, async()=>{ closeSessionActionMenu(); await deleteSession(session.session_id); }, 'danger' )); document.body.appendChild(menu); _sessionActionMenu = menu; _sessionActionAnchor = anchorEl; _sessionActionSessionId = session.session_id; anchorEl.classList.add('active'); const row=anchorEl.closest('.session-item'); if(row) row.classList.add('menu-open'); _positionSessionActionMenu(anchorEl); } document.addEventListener('click',e=>{ if(!_sessionActionMenu) return; if(_sessionActionMenu.contains(e.target)) return; if(_sessionActionAnchor && _sessionActionAnchor.contains(e.target)) return; closeSessionActionMenu(); }); document.addEventListener('scroll',e=>{ if(!_sessionActionMenu) return; if(_sessionActionMenu.contains(e.target)) return; closeSessionActionMenu(); }, true); document.addEventListener('keydown',e=>{ if(e.key==='Escape' && _sessionActionMenu) closeSessionActionMenu(); }); window.addEventListener('resize',()=>{ if(_sessionActionMenu && _sessionActionAnchor) _positionSessionActionMenu(_sessionActionAnchor); }); async function newSession(flash){ MSG_QUEUE.length=0;updateQueueBadge(); S.toolCalls=[]; clearLiveToolCards(); // Use profile default workspace for new sessions after a profile switch (one-shot), // otherwise inherit from the current session (or let server pick the default) const inheritWs=S._profileDefaultWorkspace||(S.session?S.session.workspace:null); S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})}); S.session=data.session;S.messages=data.session.messages||[]; if(flash)S.session._flash=true; localStorage.setItem('hermes-webui-session',S.session.session_id); syncTopbar();await loadDir('.');renderMessages(); // don't call renderSessionList here - callers do it when needed } async function loadSession(sid){ stopApprovalPolling();hideApprovalCard(); const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`); S.session=data.session; localStorage.setItem('hermes-webui-session',S.session.session_id); // B9: sanitize empty assistant messages that can appear when agent only ran tool calls data.session.messages=(data.session.messages||[]).filter(m=>{ if(!m||!m.role)return false; if(m.role==='tool')return false; if(m.role==='assistant'){let c=m.content||'';if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('');return String(c).trim().length>0;} return true; }); if(INFLIGHT[sid]){ S.messages=INFLIGHT[sid].messages; // Restore live tool cards for this in-flight session clearLiveToolCards(); for(const tc of (S.toolCalls||[])){ if(tc&&tc.name) appendLiveToolCard(tc); } syncTopbar();await loadDir('.');renderMessages();appendThinking(); setBusy(true);setStatus((window._botName||'Hermes')+' is thinking\u2026'); startApprovalPolling(sid); }else{ MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session S.messages=data.session.messages||[]; S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true})); // Reset per-session visual state: the viewed session is idle even if another // session's stream is still running in the background. // We directly update the DOM instead of calling setBusy(false), because // setBusy(false) drains MSG_QUEUE which we don't want here. S.busy=false; S.activeStreamId=null; $('btnSend').disabled=false; $('btnSend').style.opacity='1'; const _dots=$('activityDots');if(_dots)_dots.style.display='none'; const _cb=$('btnCancel');if(_cb)_cb.style.display='none'; setStatus(''); clearLiveToolCards(); syncTopbar();await loadDir('.');renderMessages();highlightCode(); } } let _allSessions = []; // cached for search filter let _renamingSid = null; // session_id currently being renamed (blocks list re-renders) let _showArchived = false; // toggle to show archived sessions let _allProjects = []; // cached project list let _activeProject = null; // project_id filter (null = show all) let _showAllProfiles = false; // false = filter to active profile only async function renderSessionList(){ try{ if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; const [sessData, projData] = await Promise.all([ api('/api/sessions'), api('/api/projects'), ]); _allSessions = sessData.sessions||[]; _allProjects = projData.projects||[]; renderSessionListFromCache(); // no-ops if rename is in progress }catch(e){console.warn('renderSessionList',e);} } // ── Gateway session SSE (real-time sync for agent sessions) ── let _gatewaySSE = null; function startGatewaySSE(){ stopGatewaySSE(); if(!window._showCliSessions) return; try{ _gatewaySSE = new EventSource('/api/sessions/gateway/stream'); _gatewaySSE.addEventListener('sessions_changed', (ev) => { try{ const data = JSON.parse(ev.data); if(data.sessions){ renderSessionList(); // re-fetch and re-render } }catch(e){ /* ignore parse errors */ } }); _gatewaySSE.onerror = () => { // EventSource auto-reconnects; no action needed }; }catch(e){ /* SSE not available */ } } function stopGatewaySSE(){ if(_gatewaySSE){ _gatewaySSE.close(); _gatewaySSE = null; } } let _searchDebounceTimer = null; let _contentSearchResults = []; // results from /api/sessions/search content scan function filterSessions(){ // Immediate client-side title filter (no flicker) renderSessionListFromCache(); // Debounced content search via API for message text const q = ($('sessionSearch').value || '').trim(); clearTimeout(_searchDebounceTimer); if (!q) { _contentSearchResults = []; return; } _searchDebounceTimer = setTimeout(async () => { try { const data = await api(`/api/sessions/search?q=${encodeURIComponent(q)}&content=1&depth=5`); const titleIds = new Set(_allSessions.filter(s => (s.title||'Untitled').toLowerCase().includes(q.toLowerCase())).map(s=>s.session_id)); _contentSearchResults = (data.sessions||[]).filter(s => s.match_type === 'content' && !titleIds.has(s.session_id)); renderSessionListFromCache(); } catch(e) { /* ignore */ } }, 350); } function renderSessionListFromCache(){ // Don't re-render while user is actively renaming a session (would destroy the input) if(_renamingSid) return; const q=($('sessionSearch').value||'').toLowerCase(); const titleMatches=q?_allSessions.filter(s=>(s.title||'Untitled').toLowerCase().includes(q)):_allSessions; // Merge content matches (deduped): content matches appended after title matches const titleIds=new Set(titleMatches.map(s=>s.session_id)); const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches; // Filter by active profile (unless "All profiles" is toggled on) // Server backfills profile='default' for legacy sessions, so every session has a profile. // Show only sessions tagged to the active profile; 'All profiles' toggle overrides. const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>s.is_cli_session||s.profile===S.activeProfile); // Filter by active project const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered; // Filter archived unless toggle is on const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived); const archivedCount=projectFiltered.filter(s=>s.archived).length; const list=$('sessionList');list.innerHTML=''; // Project filter bar (only when projects exist) if(_allProjects.length>0){ const bar=document.createElement('div'); bar.className='project-bar'; // "All" chip const allChip=document.createElement('span'); allChip.className='project-chip'+(!_activeProject?' active':''); allChip.textContent='All'; allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();}; bar.appendChild(allChip); // Project chips for(const p of _allProjects){ const chip=document.createElement('span'); chip.className='project-chip'+(p.project_id===_activeProject?' active':''); if(p.color){ const dot=document.createElement('span'); dot.className='color-dot'; dot.style.background=p.color; chip.appendChild(dot); } const nameSpan=document.createElement('span'); nameSpan.textContent=p.name; chip.appendChild(nameSpan); chip.onclick=()=>{_activeProject=p.project_id;renderSessionListFromCache();}; chip.ondblclick=(e)=>{e.stopPropagation();_startProjectRename(p,chip);}; chip.oncontextmenu=(e)=>{e.preventDefault();_confirmDeleteProject(p);}; bar.appendChild(chip); } // Create button const addBtn=document.createElement('button'); addBtn.className='project-create-btn'; addBtn.textContent='+'; addBtn.title='New project'; addBtn.onclick=(e)=>{e.stopPropagation();_startProjectCreate(bar,addBtn);}; bar.appendChild(addBtn); list.appendChild(bar); } // Profile filter toggle (show sessions from other profiles) const otherProfileCount=allMatched.filter(s=>s.profile&&s.profile!==S.activeProfile).length; if(otherProfileCount>0&&!_showAllProfiles){ const pfToggle=document.createElement('div'); pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;'; pfToggle.textContent='Show '+otherProfileCount+' from other profiles'; pfToggle.onclick=()=>{_showAllProfiles=true;renderSessionListFromCache();}; list.appendChild(pfToggle); } else if(_showAllProfiles&&otherProfileCount>0){ const pfToggle=document.createElement('div'); pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;'; pfToggle.textContent='Show active profile only'; pfToggle.onclick=()=>{_showAllProfiles=false;renderSessionListFromCache();}; list.appendChild(pfToggle); } // Show/hide archived toggle if there are archived sessions if(archivedCount>0){ const toggle=document.createElement('div'); toggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;'; toggle.textContent=_showArchived?'Hide archived':'Show '+archivedCount+' archived'; toggle.onclick=()=>{_showArchived=!_showArchived;renderSessionListFromCache();}; list.appendChild(toggle); } // Empty state for active project filter if(_activeProject&&sessions.length===0){ const empty=document.createElement('div'); empty.style.cssText='padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;'; empty.textContent='No sessions in this project yet.'; list.appendChild(empty); } // Separate pinned from unpinned const pinned=sessions.filter(s=>s.pinned); const unpinned=sessions.filter(s=>!s.pinned); // Date grouping: Pinned / Today / Yesterday / Earlier const now=Date.now(); const ONE_DAY=86400000; // Collapse state persisted in localStorage let _groupCollapsed={}; try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){} const _saveCollapsed=()=>{try{localStorage.setItem('hermes-date-groups-collapsed',JSON.stringify(_groupCollapsed));}catch(e){}}; // Group sessions by date const groups=[]; let curLabel=null,curItems=[]; if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true}); for(const s of unpinned){ const ts=(s.updated_at||s.created_at||0)*1000; const label=ts>now-ONE_DAY?'Today':ts>now-2*ONE_DAY?'Yesterday':'Earlier'; if(label!==curLabel){ if(curItems.length) groups.push({label:curLabel,items:curItems}); curLabel=label;curItems=[s]; } else { curItems.push(s); } } if(curItems.length) groups.push({label:curLabel,items:curItems}); // Render groups with collapsible headers for(const g of groups){ const wrapper=document.createElement('div'); wrapper.className='session-date-group'; const hdr=document.createElement('div'); hdr.className='session-date-header'+(g.isPinned?' pinned':''); const caret=document.createElement('span'); caret.className='session-date-caret'; caret.textContent='\u25B8'; // right-pointing triangle const label=document.createElement('span'); label.textContent=g.label; hdr.appendChild(caret);hdr.appendChild(label); const body=document.createElement('div'); body.className='session-date-body'; if(_groupCollapsed[g.label]){body.style.display='none';caret.classList.add('collapsed');} hdr.onclick=()=>{ const isCollapsed=body.style.display==='none'; body.style.display=isCollapsed?'':'none'; caret.classList.toggle('collapsed',!isCollapsed); _groupCollapsed[g.label]=!isCollapsed; _saveCollapsed(); }; wrapper.appendChild(hdr); for(const s of g.items){ body.appendChild(_renderOneSession(s)); } wrapper.appendChild(body); list.appendChild(wrapper); } // ── Render session items (extracted for group body use) ── // Note: declared after the groups loop but available via function hoisting. function _renderOneSession(s){ const el=document.createElement('div'); const isActive=S.session&&s.session_id===S.session.session_id; el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(s.is_cli_session?' cli-session':''); if(s.source_tag) el.dataset.source=s.source_tag; if(isActive&&S.session&&S.session._flash)delete S.session._flash; const rawTitle=s.title||'Untitled'; const tags=(rawTitle.match(/#[\w-]+/g)||[]); const cleanTitle=tags.length?rawTitle.replace(/#[\w-]+/g,'').trim():rawTitle; const title=document.createElement('span'); title.className='session-title'; title.textContent=cleanTitle||'Untitled'; title.title='Double-click to rename'; // Append tag chips after the title text for(const tag of tags){ const chip=document.createElement('span'); chip.className='session-tag'; chip.textContent=tag; chip.title='Click to filter by '+tag; chip.onclick=(e)=>{ e.stopPropagation(); const searchBox=$('sessionSearch'); if(searchBox){searchBox.value=tag;filterSessions();} }; title.appendChild(chip); } // Rename: called directly when we confirm it's a double-click const startRename=()=>{ _renamingSid = s.session_id; const inp=document.createElement('input'); inp.className='session-title-input'; inp.value=s.title||'Untitled'; ['click','mousedown','dblclick','pointerdown'].forEach(ev=> inp.addEventListener(ev, e2=>e2.stopPropagation()) ); const finish=async(save)=>{ _renamingSid = null; if(save){ const newTitle=inp.value.trim()||'Untitled'; title.textContent=newTitle; s.title=newTitle; if(S.session&&S.session.session_id===s.session_id){S.session.title=newTitle;syncTopbar();} try{await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:s.session_id,title:newTitle})});} catch(err){setStatus('Rename failed: '+err.message);} } inp.replaceWith(title); // Allow list re-renders again after a short delay setTimeout(()=>{ if(_renamingSid===null) renderSessionListFromCache(); },50); }; inp.onkeydown=e2=>{ if(e2.key==='Enter'){e2.preventDefault();e2.stopPropagation();finish(true);} if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);} }; // onblur: cancel only -- no accidental saves inp.onblur=()=>{ if(_renamingSid===s.session_id) finish(false); }; title.replaceWith(inp); setTimeout(()=>{inp.focus();inp.select();},10); }; // Pin indicator (inline, only when pinned — no space reserved otherwise) if(s.pinned){ const pinInd=document.createElement('span'); pinInd.className='session-pin-indicator'; pinInd.innerHTML=ICONS.pin; el.appendChild(pinInd); } // Project indicator: colored left border (active item keeps its own gold color) if(s.project_id){ const proj=_allProjects.find(p=>p.project_id===s.project_id); if(proj){ // project color shown via dot indicator, not left border const dot=document.createElement('span'); dot.className='session-project-dot'; dot.style.background=proj.color||'var(--blue)'; dot.title=proj.name; title.appendChild(dot); } } el.appendChild(title); const actions=document.createElement('div'); actions.className='session-actions'; const menuBtn=document.createElement('button'); menuBtn.type='button'; menuBtn.className='session-actions-trigger'; menuBtn.title='Conversation actions'; menuBtn.setAttribute('aria-haspopup','menu'); menuBtn.setAttribute('aria-label','Conversation actions'); menuBtn.innerHTML=ICONS.more; menuBtn.onclick=(e)=>{ e.stopPropagation(); e.preventDefault(); _openSessionActionMenu(s, menuBtn); }; actions.appendChild(menuBtn); el.appendChild(actions); // Use a click timer to distinguish single-click (navigate) from double-click (rename). // This prevents loadSession from firing on the first click of a double-click, // which would re-render the list and destroy the dblclick target before it fires. let _clickTimer=null; el.onclick=async(e)=>{ if(_renamingSid) return; // ignore while any rename is active if(actions.contains(e.target)) return; clearTimeout(_clickTimer); _clickTimer=setTimeout(async()=>{ _clickTimer=null; if(_renamingSid) return; // For CLI sessions, import into WebUI store first (idempotent) if(s.is_cli_session){ try{ await api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:s.session_id})}); }catch(e){ /* import failed -- fall through to read-only view */ } } await loadSession(s.session_id);renderSessionListFromCache(); if(typeof closeMobileSidebar==='function')closeMobileSidebar(); }, 220); }; el.ondblclick=async(e)=>{ e.stopPropagation(); e.preventDefault(); clearTimeout(_clickTimer); // cancel the pending single-click navigation _clickTimer=null; startRename(); }; return el; } } async function deleteSession(sid){ const _delSess=await showConfirmDialog({title:'Delete conversation',message:'This cannot be undone.',confirmLabel:'Delete',danger:true,focusCancel:true}); if(!_delSess) return; try{ await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}); }catch(e){setStatus(`Delete failed: ${e.message}`);return;} if(S.session&&S.session.session_id===sid){ S.session=null;S.messages=[];S.entries=[]; localStorage.removeItem('hermes-webui-session'); // load the most recent remaining session, or show blank if none left const remaining=await api('/api/sessions'); if(remaining.sessions&&remaining.sessions.length){ await loadSession(remaining.sessions[0].session_id); }else{ $('topbarTitle').textContent=window._botName||'Hermes'; $('topbarMeta').textContent='Start a new conversation'; $('msgInner').innerHTML=''; $('emptyState').style.display=''; $('fileTree').innerHTML=''; } } showToast('Conversation deleted'); await renderSessionList(); } // ── Project helpers ───────────────────────────────────────────────────── const PROJECT_COLORS=['#7cb9ff','#f5c542','#e94560','#50c878','#c084fc','#fb923c','#67e8f9','#f472b6']; function _showProjectPicker(session, anchorEl){ // Close any existing picker document.querySelectorAll('.project-picker').forEach(p=>p.remove()); const picker=document.createElement('div'); picker.className='project-picker'; // "No project" option const none=document.createElement('div'); none.className='project-picker-item'+(!session.project_id?' active':''); none.textContent='No project'; none.onclick=async()=>{ picker.remove(); document.removeEventListener('click',close); await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})}); session.project_id=null; renderSessionListFromCache(); showToast('Removed from project'); }; picker.appendChild(none); // Project options for(const p of _allProjects){ const item=document.createElement('div'); item.className='project-picker-item'+(session.project_id===p.project_id?' active':''); if(p.color){ const dot=document.createElement('span'); dot.className='color-dot'; dot.style.cssText='width:6px;height:6px;border-radius:50%;background:'+p.color+';flex-shrink:0;'; item.appendChild(dot); } const name=document.createElement('span'); name.textContent=p.name; item.appendChild(name); item.onclick=async()=>{ picker.remove(); document.removeEventListener('click',close); await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:p.project_id})}); session.project_id=p.project_id; renderSessionListFromCache(); showToast('Moved to '+p.name); }; picker.appendChild(item); } // "+ New project" shortcut at the bottom const createItem=document.createElement('div'); createItem.className='project-picker-item project-picker-create'; createItem.textContent='+ New project'; createItem.onclick=async()=>{ picker.remove(); document.removeEventListener('click',close); // Prompt for name inline const name=await showPromptDialog({title:'New project',message:'',placeholder:'Project name',confirmLabel:t('create')}); if(!name||!name.trim()) return; const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length]; const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color})}); if(res.project){ _allProjects.push(res.project); // Now move session into it await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:res.project.project_id})}); session.project_id=res.project.project_id; await renderSessionList(); showToast('Created "'+res.project.name+'" and moved session'); } }; picker.appendChild(createItem); // Append to body and position using getBoundingClientRect so it isn't clipped // by overflow:hidden on .session-item ancestors document.body.appendChild(picker); const rect=anchorEl.getBoundingClientRect(); picker.style.position='fixed'; picker.style.zIndex='999'; // Prefer opening below; flip above if too close to bottom of viewport const spaceBelow=window.innerHeight-rect.bottom; if(spaceBelow<160&&rect.top>160){ picker.style.bottom=(window.innerHeight-rect.top+4)+'px'; picker.style.top='auto'; }else{ picker.style.top=(rect.bottom+4)+'px'; picker.style.bottom='auto'; } // Align right edge of picker with right edge of button; keep within viewport const pickerW=Math.min(220,Math.max(160,picker.scrollWidth||160)); let left=rect.right-pickerW; if(left<8) left=8; picker.style.left=left+'px'; // Close on outside click const close=(e)=>{if(!picker.contains(e.target)&&e.target!==anchorEl){picker.remove();document.removeEventListener('click',close);}}; setTimeout(()=>document.addEventListener('click',close),0); } async function _startProjectCreate(bar, addBtn){ const inp=document.createElement('input'); inp.className='project-create-input'; inp.placeholder='Project name'; const finish=async(save)=>{ if(save&&inp.value.trim()){ const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length]; await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})}); await renderSessionList(); showToast('Project created'); }else{ inp.replaceWith(addBtn); } }; inp.onkeydown=(e)=>{ if(e.key==='Enter'){e.preventDefault();finish(true);} if(e.key==='Escape'){e.preventDefault();finish(false);} }; inp.onblur=()=>finish(false); addBtn.replaceWith(inp); setTimeout(()=>inp.focus(),10); } function _startProjectRename(proj, chip){ const inp=document.createElement('input'); inp.className='project-create-input'; inp.value=proj.name; const finish=async(save)=>{ if(save&&inp.value.trim()&&inp.value.trim()!==proj.name){ await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})}); await renderSessionList(); showToast('Project renamed'); }else{ renderSessionListFromCache(); } }; inp.onkeydown=(e)=>{ if(e.key==='Enter'){e.preventDefault();finish(true);} if(e.key==='Escape'){e.preventDefault();finish(false);} }; inp.onblur=()=>finish(false); inp.onclick=(e)=>e.stopPropagation(); chip.replaceWith(inp); setTimeout(()=>{inp.focus();inp.select();},10); } async function _confirmDeleteProject(proj){ const _delProj=await showConfirmDialog({title:`Delete project "${proj.name}"?`,message:'Sessions will be unassigned but not deleted.',confirmLabel:'Delete',danger:true,focusCancel:true}); if(!_delProj) return; await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})}); if(_activeProject===proj.project_id) _activeProject=null; await renderSessionList(); showToast('Project deleted'); }