Hermes WebUI v0.1.0 — initial public release
This commit is contained in:
206
static/sessions.js
Normal file
206
static/sessions.js
Normal file
@@ -0,0 +1,206 @@
|
||||
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)
|
||||
|
||||
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 sessions=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
|
||||
const list=$('sessionList');list.innerHTML='';
|
||||
// Date grouping: Today / Yesterday / Earlier
|
||||
const now=Date.now();
|
||||
const ONE_DAY=86400000;
|
||||
let lastGroup='';
|
||||
for(const s of sessions.slice(0,50)){
|
||||
const ts=(s.updated_at||0)*1000;
|
||||
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':'');
|
||||
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
|
||||
const title=document.createElement('span');
|
||||
title.className='session-title';title.textContent=s.title||'Untitled';
|
||||
title.title='Double-click to rename';
|
||||
|
||||
// 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 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(title);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(e.target===trash||trash.contains(e.target)) return; // trash handles itself
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user