Files
webui/static/sessions.js
Nathan Esquenazi d4ab01c152 fix: workspace updates on profile switch; remove redundant topbar workspace chip
Two changes:

1. Workspace updates correctly on profile switch
   switchToProfile() now applies data.default_workspace from the switch
   response to the current session via /api/session/update, updates
   S.session.workspace in-memory, and stores S._profileDefaultWorkspace
   so the next new session also inherits the profile's workspace.
   newSession() in sessions.js picks up S._profileDefaultWorkspace when
   creating a new session after a profile switch.

2. Workspace chip removed from topbar
   The workspace was shown in two places: the topbar chip (wsChip) AND
   the sidebar bottom display (sidebarWsDisplay with name + full path).
   The topbar chip was redundant, cluttered the topbar, and pushed other
   chips (profile, model, clear, settings) off screen.
   Removed wsChip from the topbar entirely. The sidebar display is now
   the sole workspace UI, consistent and unambiguous.
   Moved wsDropdown to live inside the sidebar position:relative wrapper
   so it opens downward from sidebarWsDisplay. Updated the click-outside
   listener to close on clicks outside sidebarWsDisplay/wsDropdown.
   Removed stale wsChip update code from syncTopbar() in ui.js.

Tests: 426 passed, 0 failed.
2026-04-03 19:38:33 +00:00

554 lines
25 KiB
JavaScript

// ── Session action icons (SVG, monochrome, inherit currentColor) ──
const ICONS={
pin:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><polygon points="8,1.5 9.8,5.8 14.5,6.2 11,9.4 12,14 8,11.5 4,14 5,9.4 1.5,6.2 6.2,5.8"/></svg>',
unpin:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><polygon points="8,2 9.8,6.2 14.2,6.2 10.7,9.2 12,13.8 8,11 4,13.8 5.3,9.2 1.8,6.2 6.2,6.2"/></svg>',
folder:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M2 4.5h4l1.5 1.5H14v7H2z"/></svg>',
archive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><line x1="6" y1="8.5" x2="10" y2="8.5"/></svg>',
unarchive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><polyline points="6.5,7 8,5.5 9.5,7"/></svg>',
dup:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="4.5" y="4.5" width="8.5" height="8.5" rx="1.5"/><path d="M3 11.5V3h8.5"/></svg>',
trash:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>',
};
async function newSession(flash){
MSG_QUEUE.length=0;updateQueueBadge();
S.toolCalls=[];
clearLiveToolCards();
// Use profile default workspace for new sessions after a profile switch,
// otherwise inherit from the current session (or let server pick the default)
const inheritWs=S._profileDefaultWorkspace||( S.session?S.session.workspace:null);
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('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);}
}
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)
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>!s.profile||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;
let lastGroup='';
const ordered=[...pinned,...unpinned].slice(0,50);
if(pinned.length){
const hdr=document.createElement('div');
hdr.style.cssText='font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#f5c542;padding:10px 10px 4px;opacity:.9;';
hdr.textContent='\u2605 Pinned';
list.appendChild(hdr);
}
for(const s of ordered){
if(!s.pinned){
const ts=(s.updated_at||s.created_at||0)*1000; // group by last activity, not creation
const group=ts>now-ONE_DAY?'Today':ts>now-2*ONE_DAY?'Yesterday':'Earlier';
if(group!==lastGroup){
lastGroup=group;
const hdr=document.createElement('div');
hdr.style.cssText='font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:10px 10px 4px;opacity:.8;';
hdr.textContent=group;
list.appendChild(hdr);
}
}
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':'');
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){
if(!isActive) el.style.borderLeftColor=proj.color||'var(--blue)';
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);
// Action buttons overlay (appears on hover with gradient fade)
const actions=document.createElement('div');
actions.className='session-actions';
// Pin toggle
const pinBtn=document.createElement('button');
pinBtn.className='act-pin'+(s.pinned?' pinned':'');
pinBtn.innerHTML=s.pinned?ICONS.pin:ICONS.unpin;
pinBtn.title=s.pinned?'Unpin':'Pin to top';
pinBtn.onclick=async(e)=>{
e.stopPropagation();e.preventDefault();
const newPinned=!s.pinned;
try{
await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:s.session_id,pinned:newPinned})});
s.pinned=newPinned;
if(S.session&&S.session.session_id===s.session_id) S.session.pinned=newPinned;
renderSessionList();
}catch(err){showToast('Pin failed: '+err.message);}
};
actions.appendChild(pinBtn);
// Move to project
const move=document.createElement('button');
move.className='act-move';move.innerHTML=ICONS.folder;move.title='Move to project';
move.onclick=async(e)=>{e.stopPropagation();e.preventDefault();_showProjectPicker(s,move);};
actions.appendChild(move);
// Archive
const archive=document.createElement('button');
archive.className='act-archive';archive.innerHTML=s.archived?ICONS.unarchive:ICONS.archive;
archive.title=s.archived?'Unarchive':'Archive';
archive.onclick=async(e)=>{
e.stopPropagation();e.preventDefault();
try{
await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:s.session_id,archived:!s.archived})});
s.archived=!s.archived;
if(S.session&&S.session.session_id===s.session_id) S.session.archived=s.archived;
await renderSessionList();
showToast(s.archived?'Session archived':'Session restored');
}catch(err){showToast('Archive failed: '+err.message);}
};
actions.appendChild(archive);
// Duplicate
const dup=document.createElement('button');
dup.className='act-dup';dup.innerHTML=ICONS.dup;dup.title='Duplicate';
dup.onclick=async(e)=>{
e.stopPropagation();e.preventDefault();
try{
const res=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:s.workspace,model:s.model})});
if(res.session){
await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:res.session.session_id,title:(s.title||'Untitled')+' (copy)'})});
await loadSession(res.session.session_id);await renderSessionList();
showToast('Session duplicated');
}
}catch(err){showToast('Duplicate failed: '+err.message);}
};
actions.appendChild(dup);
// Trash
const trash=document.createElement('button');
trash.className='act-trash';trash.innerHTML=ICONS.trash;trash.title='Delete';
trash.onclick=async(e)=>{e.stopPropagation();e.preventDefault();await deleteSession(s.session_id);};
actions.appendChild(trash);
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;
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();
};
list.appendChild(el);
}
}
async function deleteSession(sid){
if(!confirm('Delete this conversation?'))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='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=prompt('Project name:');
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);
}
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){
if(!confirm('Delete project "'+proj.name+'"? Sessions will be unassigned but not deleted.')){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');
}