Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish

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.
This commit is contained in:
Hermes
2026-03-31 07:02:47 +00:00
parent 732d227b97
commit 7019c25021
29 changed files with 2871 additions and 1122 deletions

View File

@@ -29,7 +29,7 @@ async function send(){
$('msg').value='';autoResize();
const displayText=text||(uploaded.length?`Uploaded: ${uploaded.join(', ')}`:'(file upload)');
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploaded:undefined};
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploaded:undefined,_ts:Date.now()/1000};
S.toolCalls=[]; // clear tool calls from previous turn
clearLiveToolCards(); // clear any leftover live cards from last turn
S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true); // activity bar shown via setBusy
@@ -96,143 +96,126 @@ async function send(){
$('msgInner').appendChild(assistantRow);
}
const es=new EventSource(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`);
// ── Shared SSE handler wiring (used for initial connection and reconnect) ──
let _reconnectAttempted=false;
es.addEventListener('token',e=>{
// Guard: if the user switched sessions, don't write tokens to the wrong DOM
if(!S.session||S.session.session_id!==activeSid) return;
const d=JSON.parse(e.data);
assistantText+=d.text;
ensureAssistantRow();
assistantBody.innerHTML=renderMd(assistantText);
$('messages').scrollTop=$('messages').scrollHeight;
});
function _wireSSE(source){
source.addEventListener('token',e=>{
if(!S.session||S.session.session_id!==activeSid) return;
const d=JSON.parse(e.data);
assistantText+=d.text;
ensureAssistantRow();
assistantBody.innerHTML=renderMd(assistantText);
scrollIfPinned();
});
es.addEventListener('tool',e=>{
const d=JSON.parse(e.data);
// Only update activity bar if viewing this session
if(S.session&&S.session.session_id===activeSid){
setStatus(`${d.name}${d.preview?' · '+d.preview.slice(0,55):''}`);
}
if(!S.session||S.session.session_id!==activeSid) return;
removeThinking();
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
// Append card to the stable live container -- no renderMessages() call
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false};
S.toolCalls.push(tc);
appendLiveToolCard(tc);
$('messages').scrollTop=$('messages').scrollHeight;
});
es.addEventListener('approval',e=>{
const d=JSON.parse(e.data);
// Tag the approval with the session that owns it so respondApproval uses correct sid
d._session_id=activeSid;
showApprovalCard(d);
});
es.addEventListener('done',e=>{
es.close();
const d=JSON.parse(e.data);
delete INFLIGHT[activeSid];
clearInflight();
stopApprovalPolling();
// Only hide approval card if it belongs to the session that just finished
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
// Only clear active stream state if this is the currently viewed session
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
}
if(S.session&&S.session.session_id===activeSid){
S.session=d.session;S.messages=d.session.messages||[];
// Populate tool calls from server-extracted metadata (has snippets)
if(d.session.tool_calls&&d.session.tool_calls.length){
S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));
} else {
S.toolCalls=S.toolCalls.map(tc=>({...tc,done:true}));
source.addEventListener('tool',e=>{
const d=JSON.parse(e.data);
if(S.session&&S.session.session_id===activeSid){
setStatus(`${d.name}${d.preview?' · '+d.preview.slice(0,55):''}`);
}
if(uploaded.length){
const lastUser=[...S.messages].reverse().find(m=>m.role==='user');
if(lastUser)lastUser.attachments=uploaded;
}
clearLiveToolCards();
// Set S.busy=false BEFORE renderMessages so the settled tool card
// block (!S.busy guard) can render the final grouped cards.
S.busy=false;
syncTopbar();renderMessages();loadDir('.');
}
renderSessionList();setBusy(false);setStatus('');
});
if(!S.session||S.session.session_id!==activeSid) return;
removeThinking();
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false};
S.toolCalls.push(tc);
appendLiveToolCard(tc);
scrollIfPinned();
});
es.addEventListener('error',e=>{
es.close();
delete INFLIGHT[activeSid];
clearInflight();
stopApprovalPolling();
// Only hide approval card if it belongs to the session that just finished
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
}
let msg='Connection error';
try{const d=JSON.parse(e.data);msg=d.message||msg;}catch(_){}
if(S.session&&S.session.session_id===activeSid){
clearLiveToolCards();
if(!assistantText){removeThinking();}
S.messages.push({role:'assistant',content:`**Error:** ${msg}`});
renderMessages();
}
if(!S.session || !INFLIGHT[S.session.session_id]){
setBusy(false);setStatus('Error: '+msg);
}
});
source.addEventListener('approval',e=>{
const d=JSON.parse(e.data);
d._session_id=activeSid;
showApprovalCard(d);
});
es.addEventListener('cancel',e=>{
es.close();
delete INFLIGHT[activeSid];
clearInflight();
stopApprovalPolling();
// Only hide approval card if it belongs to the session that just finished
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
const _cbc=$('btnCancel');if(_cbc)_cbc.style.display='none';
}
if(S.session&&S.session.session_id===activeSid){
clearLiveToolCards();
if(!assistantText){removeThinking();}
S.messages.push({role:'assistant',content:'*Task cancelled.*'});
renderMessages();
}
renderSessionList();
if(!S.session || !INFLIGHT[S.session.session_id]){
setBusy(false);setStatus('');
}
});
// Handle SSE connection errors (network drop etc)
es.onerror=()=>{
if(es.readyState===EventSource.CLOSED){
source.addEventListener('done',e=>{
source.close();
const d=JSON.parse(e.data);
delete INFLIGHT[activeSid];
clearInflight();
stopApprovalPolling();
// Only hide approval card if it belongs to the session that just finished
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
const _cbo=$('btnCancel');if(_cbo)_cbo.style.display='none';
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
}
if(!assistantText&&S.session&&S.session.session_id===activeSid){
removeThinking();
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});
renderMessages();
if(S.session&&S.session.session_id===activeSid){
S.session=d.session;S.messages=d.session.messages||[];
if(d.session.tool_calls&&d.session.tool_calls.length){
S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));
} else {
S.toolCalls=S.toolCalls.map(tc=>({...tc,done:true}));
}
if(uploaded.length){
const lastUser=[...S.messages].reverse().find(m=>m.role==='user');
if(lastUser)lastUser.attachments=uploaded;
}
clearLiveToolCards();
S.busy=false;
syncTopbar();renderMessages();loadDir('.');
}
if(!S.session || !INFLIGHT[S.session.session_id]){
setBusy(false);setStatus('');
renderSessionList();setBusy(false);setStatus('');
});
source.addEventListener('error',e=>{
source.close();
// Attempt one reconnect if the stream is still active server-side
if(!_reconnectAttempted && streamId){
_reconnectAttempted=true;
setStatus('Connection lost \u2014 reconnecting\u2026');
setTimeout(async()=>{
try{
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
if(st.active){
setStatus('Reconnected');
_wireSSE(new EventSource(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`));
return;
}
}catch(_){}
_handleStreamError();
},1500);
return;
}
_handleStreamError();
});
source.addEventListener('cancel',e=>{
source.close();
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;const _cbc=$('btnCancel');if(_cbc)_cbc.style.display='none';
}
if(S.session&&S.session.session_id===activeSid){
clearLiveToolCards();if(!assistantText)removeThinking();
S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages();
}
renderSessionList();
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('');}
});
}
function _handleStreamError(){
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
clearLiveToolCards();if(!assistantText)removeThinking();
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages();
}else{
// User switched away — show background error banner
if(typeof trackBackgroundError==='function'){
// Look up session title from the session list cache so the banner names it correctly
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
trackBackgroundError(activeSid,_errTitle,'Connection lost');
}
}
};
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('Error: Connection lost');}
}
_wireSSE(new EventSource(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`));
}
function transcript(){