Sprint 11 (v0.13): multi-provider model support, streaming smoothness - Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic, Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal) - Scroll pinning during streaming (no forced scroll when user has scrolled up) - All route handlers extracted to api/routes.py (server.py now ~76 lines) Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL - Settings panel (gear icon) -- persist default model and workspace server-side - SSE auto-reconnect on network blips - Pin/star sessions to top of sidebar - Import session from JSON export Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title - Cron completion alerts: toast per completion + unread badge on Tasks tab - Background agent error banner when a non-active session errors mid-stream - Session duplicate button - Browser tab title reflects active session name Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps - Mermaid diagram rendering inline (dark theme, lazy CDN load) - File rename (double-click in file tree) and create folder - Session archive (hide without deleting, toggle to show) - Session tags -- #hashtag in title becomes colored chip + click-to-filter - Message timestamps (HH:MM on hover, full date as tooltip) Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
288 lines
13 KiB
JavaScript
288 lines
13 KiB
JavaScript
async function newSession(flash){
|
|
MSG_QUEUE.length=0;updateQueueBadge();
|
|
S.toolCalls=[];
|
|
clearLiveToolCards();
|
|
const inheritWs=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
|
|
|
|
async function renderSessionList(){
|
|
try{
|
|
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
|
|
const data=await api('/api/sessions');
|
|
_allSessions = data.sessions||[];
|
|
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 archived unless toggle is on
|
|
const sessions=_showArchived?allMatched:allMatched.filter(s=>!s.archived);
|
|
const archivedCount=allMatched.filter(s=>s.archived).length;
|
|
const list=$('sessionList');list.innerHTML='';
|
|
// 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);
|
|
}
|
|
// 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);
|
|
};
|
|
|
|
const pin=document.createElement('span');
|
|
pin.className='session-pin'+(s.pinned?' pinned':'');
|
|
pin.innerHTML=s.pinned?'★':'☆';
|
|
pin.title=s.pinned?'Unpin':'Pin to top';
|
|
pin.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);}
|
|
};
|
|
const archive=document.createElement('button');
|
|
archive.className='session-action-btn';archive.innerHTML=s.archived?'✉':'📦';
|
|
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);}
|
|
};
|
|
const dup=document.createElement('button');
|
|
dup.className='session-dup';dup.innerHTML='⧉';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);}
|
|
};
|
|
const trash=document.createElement('button');
|
|
trash.className='session-trash';trash.innerHTML='🗑';trash.title='Delete';
|
|
trash.onclick=async(e)=>{e.stopPropagation();e.preventDefault();await deleteSession(s.session_id);};
|
|
el.appendChild(pin);el.appendChild(title);el.appendChild(archive);el.appendChild(dup);el.appendChild(trash);
|
|
|
|
// 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([trash,dup,archive].some(b=>e.target===b||b.contains(e.target))) return;
|
|
clearTimeout(_clickTimer);
|
|
_clickTimer=setTimeout(async()=>{
|
|
_clickTimer=null;
|
|
if(_renamingSid) return;
|
|
await loadSession(s.session_id);renderSessionListFromCache();
|
|
}, 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();
|
|
}
|
|
|
|
|