From 7019c25021119108b2fa9f28cae7ca06fec12aa5 Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 31 Mar 2026 07:02:47 +0000 Subject: [PATCH] =?UTF-8?q?Hermes=20Web=20UI=20=E2=80=94=20Sprints=2011-14?= =?UTF-8?q?:=20multi-provider=20models,=20settings,=20session=20QoL,=20ale?= =?UTF-8?q?rts,=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 1 + ARCHITECTURE.md | 282 +++++------- CHANGELOG.md | 114 ++++- README.md | 49 +- ROADMAP.md | 34 +- SPRINTS.md | 203 +++++---- TESTING.md | 2 +- api/__init__.py | 2 +- api/config.py | 247 +++++++++- api/helpers.py | 2 +- api/models.py | 16 +- api/routes.py | 932 ++++++++++++++++++++++++++++++++++++++ api/streaming.py | 2 +- api/upload.py | 2 +- api/workspace.py | 2 +- server.py | 676 +-------------------------- static/boot.js | 23 + static/index.html | 25 +- static/messages.js | 233 +++++----- static/panels.js | 171 +++++++ static/sessions.js | 111 ++++- static/style.css | 50 ++ static/ui.js | 194 +++++++- tests/test_regressions.py | 70 ++- tests/test_sprint1.py | 2 +- tests/test_sprint11.py | 96 ++++ tests/test_sprint12.py | 179 ++++++++ tests/test_sprint13.py | 120 +++++ tests/test_sprint14.py | 153 +++++++ 29 files changed, 2871 insertions(+), 1122 deletions(-) create mode 100644 api/routes.py create mode 100644 tests/test_sprint11.py create mode 100644 tests/test_sprint12.py create mode 100644 tests/test_sprint13.py create mode 100644 tests/test_sprint14.py diff --git a/.gitignore b/.gitignore index 01e44a7..0463bb7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ archive/ .env .env.* !.env.example +.claude/* # Generated screenshots and transient artifacts screenshot-*.png diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bdaafba..474d251 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,39 +11,63 @@ ## 1. Overview and Purpose -The Hermes Web UI is a lightweight, single-file web application that gives you -a browser-based interface to the Hermes agent that is functionally equivalent to the CLI. -It is modeled on the Claude interface: a three-panel layout with a sidebar for -session management, a central chat area, and a right panel for workspace file browsing. +The Hermes Web UI is a lightweight web application that gives you a browser-based +interface to the Hermes agent that is functionally equivalent to the CLI. It is modeled on +the Claude-style interface: a three-panel layout with a sidebar for session management, +a central chat area, and a right panel for workspace file browsing. The design philosophy is deliberately minimal. There is no build step, no bundler, no -frontend framework. Everything ships from a single Python file. This makes the code easy -to modify from a terminal or by an agent, but it creates architectural debt that grows as -the feature set expands. +frontend framework. The Python server is split into a routing shell (server.py) and +business logic modules (api/). The frontend is six vanilla JS modules loaded from static/. +This makes the code easy to modify from a terminal or by an agent. --- ## 2. File Inventory - /webui-mvp/ - server.py Main server file. ~1150 lines. Pure Python. - HTTP server, all API handlers, Session model, SSE engine, - approval wiring, file upload parser. No inline HTML/CSS/JS. - (Phase A+E complete: HTML/CSS/JS all extracted to static/) - server.py.bak Backup from a prior iteration. Kept for reference. - server_new.py Intermediate ~900-line draft. Superseded by server.py. - Safe to delete once Wave 1 begins. - start.sh Convenience script: kills running instance, starts server.py - via nohup, writes stdout/stderr to /tmp/webui-mvp.log - AGENTS.md Instruction file for agents working in this directory. - ROADMAP.md Feature and product roadmap document. - ARCHITECTURE.md THIS FILE. + / + server.py Thin routing shell + HTTP Handler. ~76 lines. Pure Python. + Delegates all route handling to api/routes.py. + start.sh Discovery script: finds agent dir, Python, starts server. + api/ + __init__.py Package marker + routes.py All GET + POST route handlers (~802 lines) + config.py Shared configuration, constants, global state, model discovery (~453 lines) + helpers.py HTTP helpers: j(), bad(), require(), safe_resolve() (~57 lines) + models.py Session model + CRUD (~114 lines) + workspace.py File ops: list_dir, read_file_content, workspace helpers (~77 lines) + upload.py Multipart parser, file upload handler (~77 lines) + streaming.py SSE engine, run_agent integration, cancel support (~218 lines) + static/ + index.html HTML template (served from disk) + style.css All CSS + ui.js DOM helpers, renderMd, tool cards, model dropdown (~671 lines) + workspace.js File tree, preview, file ops (~168 lines) + sessions.js Session CRUD, list rendering, search (~206 lines) + messages.js send(), SSE event handlers, approval, transcript (~310 lines) + panels.js Cron, skills, memory, workspace, todo, switchPanel (~600 lines) + boot.js Event wiring + boot IIFE (~154 lines) + tests/ + conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines) + test_sprint1-11.py Feature tests per sprint (13 files) + test_regressions.py Permanent regression gate + AGENTS.md Instruction file for agents working in this directory. + ROADMAP.md Feature and product roadmap document. + SPRINTS.md Forward sprint plan with CLI + Claude parity targets. + ARCHITECTURE.md THIS FILE. + TESTING.md Manual browser test plan and automated coverage reference. + CHANGELOG.md Release notes per sprint. + PORTABILITY.md Portability design spec for download-and-run installs. + requirements.txt Python dependencies. + .env.example Sample environment variable overrides. State directory (runtime data, separate from source): ~/.hermes/webui-mvp/ sessions/ One JSON file per session: {session_id}.json - test-workspace/ Default empty workspace used during development + workspaces.json Registered workspaces list + last_workspace.txt Last-used workspace path + settings.json (future) User settings Log file: @@ -301,13 +325,21 @@ read_file_content(workspace, rel): ### 5.1 Structure -The entire frontend is ~750 lines inside the HTML Python raw string. -Structure: with CSS only (no external stylesheets), with three-panel layout, - diff --git a/static/messages.js b/static/messages.js index 31a619a..5929d36 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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(){ diff --git a/static/panels.js b/static/panels.js index cf36959..0cff2e0 100644 --- a/static/panels.js +++ b/static/panels.js @@ -597,4 +597,175 @@ document.addEventListener('dragenter',e=>{e.preventDefault();if(e.dataTransfer.t document.addEventListener('dragleave',e=>{dragCounter--;if(dragCounter<=0){dragCounter=0;wrap.classList.remove('drag-over');}}); document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.classList.remove('drag-over');const files=Array.from(e.dataTransfer.files);if(files.length){addFiles(files);$('msg').focus();}}); +// ── Settings panel ─────────────────────────────────────────────────────────── + +function toggleSettings(){ + const overlay=$('settingsOverlay'); + if(!overlay) return; + if(overlay.style.display==='none'){ + overlay.style.display=''; + loadSettingsPanel(); + } else { + overlay.style.display='none'; + } +} + +async function loadSettingsPanel(){ + try{ + const settings=await api('/api/settings'); + // Populate model dropdown from /api/models + const modelSel=$('settingsModel'); + if(modelSel){ + modelSel.innerHTML=''; + try{ + const models=await api('/api/models'); + for(const g of (models.groups||[])){ + const og=document.createElement('optgroup'); + og.label=g.provider; + for(const m of g.models){ + const opt=document.createElement('option'); + opt.value=m.id;opt.textContent=m.label; + og.appendChild(opt); + } + modelSel.appendChild(og); + } + }catch(e){} + modelSel.value=settings.default_model||''; + } + // Populate workspace dropdown from /api/workspaces + const wsSel=$('settingsWorkspace'); + if(wsSel){ + wsSel.innerHTML=''; + try{ + const wsData=await api('/api/workspaces'); + for(const w of (wsData.workspaces||[])){ + const opt=document.createElement('option'); + opt.value=w.path;opt.textContent=w.name||w.path; + wsSel.appendChild(opt); + } + }catch(e){} + wsSel.value=settings.default_workspace||''; + } + }catch(e){ + showToast('Failed to load settings: '+e.message); + } +} + +async function saveSettings(){ + const model=($('settingsModel')||{}).value; + const workspace=($('settingsWorkspace')||{}).value; + const body={}; + if(model) body.default_model=model; + if(workspace) body.default_workspace=workspace; + try{ + await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); + showToast('Settings saved'); + toggleSettings(); + }catch(e){ + showToast('Save failed: '+e.message); + } +} + +// Close settings on overlay click (not panel click) +document.addEventListener('click',e=>{ + const overlay=$('settingsOverlay'); + if(overlay&&e.target===overlay) toggleSettings(); +}); + +// ── Cron completion alerts ──────────────────────────────────────────────────── + +let _cronPollSince=Date.now()/1000; // track from page load +let _cronPollTimer=null; +let _cronUnreadCount=0; + +function startCronPolling(){ + if(_cronPollTimer) return; + _cronPollTimer=setInterval(async()=>{ + if(document.hidden) return; // don't poll when tab is in background + try{ + const data=await api(`/api/crons/recent?since=${_cronPollSince}`); + if(data.completions&&data.completions.length>0){ + for(const c of data.completions){ + const icon=c.status==='error'?'\u274c':'\u2705'; + showToast(`${icon} Cron "${c.name}" ${c.status==='error'?'failed':'completed'}`,4000); + _cronPollSince=Math.max(_cronPollSince,c.completed_at); + } + _cronUnreadCount+=data.completions.length; + updateCronBadge(); + } + }catch(e){} + },30000); +} + +function updateCronBadge(){ + const tab=document.querySelector('.nav-tab[data-panel="tasks"]'); + if(!tab) return; + let badge=tab.querySelector('.cron-badge'); + if(_cronUnreadCount>0){ + if(!badge){ + badge=document.createElement('span'); + badge.className='cron-badge'; + tab.style.position='relative'; + tab.appendChild(badge); + } + badge.textContent=_cronUnreadCount>9?'9+':_cronUnreadCount; + badge.style.display=''; + }else if(badge){ + badge.style.display='none'; + } +} + +// Clear cron badge when Tasks tab is opened +const _origSwitchPanel=switchPanel; +switchPanel=async function(name){ + if(name==='tasks'){_cronUnreadCount=0;updateCronBadge();} + return _origSwitchPanel(name); +}; + +// Start polling on page load +startCronPolling(); + +// ── Background agent error tracking ────────────────────────────────────────── + +const _backgroundErrors=[]; // {session_id, title, message, ts} + +function trackBackgroundError(sessionId, title, message){ + // Only track if user is NOT currently viewing this session + if(S.session&&S.session.session_id===sessionId) return; + _backgroundErrors.push({session_id:sessionId, title:title||'Untitled', message, ts:Date.now()}); + showErrorBanner(); +} + +function showErrorBanner(){ + let banner=$('bgErrorBanner'); + if(!banner){ + banner=document.createElement('div'); + banner.id='bgErrorBanner'; + banner.className='bg-error-banner'; + const msgs=document.querySelector('.messages'); + if(msgs) msgs.parentNode.insertBefore(banner,msgs); + else document.body.appendChild(banner); + } + const latest=_backgroundErrors[0]; // FIFO: show oldest (first) error + if(!latest){banner.style.display='none';return;} + const count=_backgroundErrors.length; + banner.innerHTML=`\u26a0 ${count>1?count+' sessions have':'"'+esc(latest.title)+'" has'} encountered an error
`; + banner.style.display=''; +} + +function navigateToErrorSession(){ + const latest=_backgroundErrors.shift(); // FIFO: show oldest error first + if(latest){ + loadSession(latest.session_id);renderSessionList(); + } + if(_backgroundErrors.length===0) dismissErrorBanner(); + else showErrorBanner(); +} + +function dismissErrorBanner(){ + _backgroundErrors.length=0; + const banner=$('bgErrorBanner'); + if(banner) banner.style.display='none'; +} + // Event wiring diff --git a/static/sessions.js b/static/sessions.js index 172a105..502fb96 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -55,6 +55,7 @@ async function loadSession(sid){ 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{ @@ -92,29 +93,69 @@ function renderSessionListFromCache(){ 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 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=''; - // Date grouping: Today / Yesterday / Earlier + // 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=''; - 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 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':''); + 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=s.title||'Untitled'; + 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=()=>{ @@ -149,10 +190,50 @@ function renderSessionListFromCache(){ 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(title);el.appendChild(trash); + 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, @@ -160,7 +241,7 @@ function renderSessionListFromCache(){ 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 + if([trash,dup,archive].some(b=>e.target===b||b.contains(e.target))) return; clearTimeout(_clickTimer); _clickTimer=setTimeout(async()=>{ _clickTimer=null; diff --git a/static/style.css b/static/style.css index 3e3b218..f862e7b 100644 --- a/static/style.css +++ b/static/style.css @@ -448,3 +448,53 @@ body.resizing{user-select:none;cursor:col-resize;} ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:3px;} ::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22);} * { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.12) transparent; } + +/* ── Settings overlay ── */ +.settings-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;} +.settings-panel{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:0;width:380px;max-width:90vw;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,.5);} +.settings-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px 12px;border-bottom:1px solid var(--border);} +.settings-body{padding:20px;} +.settings-field{margin-bottom:16px;} +.settings-field label{display:block;font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:6px;} +/* Save button inside the settings panel */ +.settings-panel .settings-btn{background:var(--accent);color:#fff;border:none;border-radius:6px;padding:8px 16px;cursor:pointer;font-weight:600;font-size:13px;} +.settings-panel .settings-btn:hover{opacity:.9;} +/* Gear icon in topbar -- muted chip, no red */ +.gear-btn{font-size:13px;cursor:pointer;transition:color .15s,background .15s;} +.gear-btn:hover{color:var(--text);background:rgba(255,255,255,.08);} + +/* ── Session pin star ── */ +.session-pin{font-size:12px;cursor:pointer;opacity:0;transition:opacity .15s;padding:2px 4px;flex-shrink:0;} +.session-item:hover .session-pin,.session-pin.pinned{opacity:1;} +.session-pin.pinned{color:#f5c542;} + +/* ── Session duplicate button ── */ +.session-dup,.session-action-btn{background:none;border:none;color:var(--muted);font-size:11px;cursor:pointer;opacity:0;transition:opacity .15s;padding:2px 4px;flex-shrink:0;} +.session-item:hover .session-dup,.session-item:hover .session-action-btn{opacity:1;} +.session-dup:hover,.session-action-btn:hover{color:var(--text);} + +/* ── Cron alert badge ── */ +.cron-badge{position:absolute;top:2px;right:2px;background:#e53e3e;color:#fff;font-size:9px;font-weight:700;min-width:14px;height:14px;line-height:14px;text-align:center;border-radius:7px;padding:0 3px;} + +/* ── Background error banner ── */ +/* ── Archived sessions ── */ +.session-item.archived{opacity:.5;} +.session-item.archived .session-title{font-style:italic;} + +/* ── Session tags ── */ +.session-tag{display:inline-block;font-size:9px;font-weight:600;padding:1px 5px;margin-left:4px;border-radius:3px;background:rgba(99,179,237,.2);color:#63b3ed;cursor:pointer;vertical-align:middle;} +.session-tag:hover{background:rgba(99,179,237,.35);} + +/* ── File rename input ── */ +.file-rename-input{background:rgba(255,255,255,.08);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:12px;padding:1px 4px;width:100%;outline:none;min-width:0;} + +/* ── Message timestamps ── */ +.msg-time{font-size:10px;color:var(--muted);opacity:.6;margin-left:6px;} +.msg-role:hover .msg-time{opacity:1;} + +/* ── Mermaid diagrams ── */ +.mermaid-block{background:var(--code-bg);border-radius:8px;padding:16px;margin:8px 0;overflow-x:auto;} +.mermaid-rendered{background:transparent;padding:8px 0;} +.mermaid-rendered svg{max-width:100%;height:auto;} + +.bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;} diff --git a/static/ui.js b/static/ui.js index 0d303f5..dbd54aa 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4,8 +4,88 @@ const MSG_QUEUE=[]; // messages queued while a request is in-flight const $=id=>document.getElementById(id); const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map +let _dynamicModelLabels={}; + +async function populateModelDropdown(){ + const sel=$('modelSelect'); + if(!sel) return; + try{ + const data=await fetch('/api/models').then(r=>r.json()); + if(!data.groups||!data.groups.length) return; // keep HTML defaults + // Clear existing options + sel.innerHTML=''; + _dynamicModelLabels={}; + for(const g of data.groups){ + const og=document.createElement('optgroup'); + og.label=g.provider; + for(const m of g.models){ + const opt=document.createElement('option'); + opt.value=m.id; + opt.textContent=m.label; + og.appendChild(opt); + _dynamicModelLabels[m.id]=m.label; + } + sel.appendChild(og); + } + // Set default model from server if no localStorage preference + if(data.default_model && !localStorage.getItem('hermes-webui-model')){ + sel.value=data.default_model; + // If the default isn't in the list, add it + if(sel.value!==data.default_model){ + const opt=document.createElement('option'); + opt.value=data.default_model; + opt.textContent=data.default_model.split('/').pop(); + sel.insertBefore(opt,sel.firstChild); + sel.value=data.default_model; + } + } + }catch(e){ + // API unavailable -- keep the hardcoded HTML options as fallback + console.warn('Failed to load models from server:',e.message); + } +} + +// ── Scroll pinning ────────────────────────────────────────────────────────── +// When streaming, auto-scroll only if the user hasn't manually scrolled up. +// Once the user scrolls back to within 80px of the bottom, re-pin. +let _scrollPinned=true; +(function(){ + const el=document.getElementById('messages'); + if(!el) return; + el.addEventListener('scroll',()=>{ + const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<80; + _scrollPinned=nearBottom; + }); +})(); +function scrollIfPinned(){ + if(!_scrollPinned) return; + const el=$('messages'); + if(el) el.scrollTop=el.scrollHeight; +} +function scrollToBottom(){ + _scrollPinned=true; + const el=$('messages'); + if(el) el.scrollTop=el.scrollHeight; +} + +function getModelLabel(modelId){ + if(!modelId) return 'Unknown'; + // Check dynamic labels first, then fall back to splitting the ID + if(_dynamicModelLabels[modelId]) return _dynamicModelLabels[modelId]; + // Static fallback for common models + const STATIC_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-2.5-pro':'Gemini 2.5 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3','meta-llama/llama-4-scout':'Llama 4 Scout'}; + if(STATIC_LABELS[modelId]) return STATIC_LABELS[modelId]; + return modelId.split('/').pop()||'Unknown'; +} + function renderMd(raw){ let s=raw||''; + // Mermaid blocks: render as diagram containers (processed after DOM insertion) + s=s.replace(/```mermaid\n?([\s\S]*?)```/g,(_,code)=>{ + const id='mermaid-'+Math.random().toString(36).slice(2,10); + return `
${esc(code.trim())}
`; + }); s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`
${esc(lang)}
`:'';return `${h}
${esc(code.replace(/\n$/,''))}
`;}); s=s.replace(/`([^`\n]+)`/g,(_,c)=>`${esc(c)}`); s=s.replace(/\*\*\*(.+?)\*\*\*/g,'$1'); @@ -174,6 +254,7 @@ async function checkInflightOnBoot(sid) { function syncTopbar(){ if(!S.session){ + document.title='Hermes'; // Show default workspace name even without a session const sidebarName=$('sidebarWsName'); if(sidebarName && sidebarName.textContent==='Workspace'){ @@ -181,17 +262,26 @@ function syncTopbar(){ } return; } - $('topbarTitle').textContent=S.session.title||'Untitled'; + const sessionTitle=S.session.title||'Untitled'; + $('topbarTitle').textContent=sessionTitle; + document.title=sessionTitle+' \u2014 Hermes'; const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool'); $('topbarMeta').textContent=`${vis.length} messages`; const m=S.session.model||''; - const MODEL_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-2.5-pro':'Gemini 2.5 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3','meta-llama/llama-4-scout':'Llama 4 Scout'}; $('modelSelect').value=m; // set dropdown first so chip reads consistent value + // If session model isn't in the dropdown, add it dynamically + if(m && $('modelSelect').value!==m){ + const opt=document.createElement('option'); + opt.value=m; + opt.textContent=getModelLabel(m); + $('modelSelect').appendChild(opt); + $('modelSelect').value=m; + } // Show Clear button only when session has messages const clearBtn=$('btnClearConv'); - if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(m=>m.role!=='tool').length>0)?'':'none'; + if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none'; const displayModel=$('modelSelect').value||m; - $('modelChip').textContent=MODEL_LABELS[displayModel]||(displayModel.split('/').pop()||'Unknown'); + $('modelChip').textContent=getModelLabel(displayModel); const ws=S.session.workspace||''; $('wsChip').textContent=ws.split('/').slice(-2).join('/')||ws; // Update workspace chip in topbar with friendly name from workspace list @@ -250,7 +340,9 @@ function renderMessages(){ // Action buttons for this bubble const editBtn = isUser ? `` : ''; const retryBtn = isLastAssistant ? `` : ''; - row.innerHTML=`
${isUser?'Y':'H'}
${isUser?'You':'Hermes'}${editBtn}${retryBtn}
${filesHtml}
${bodyHtml}
`; + const tsVal=m._ts||m.timestamp; + const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():''; + row.innerHTML=`
${isUser?'Y':'H'}
${isUser?'You':'Hermes'}${tsTitle?`${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`:''}${editBtn}${retryBtn}
${filesHtml}
${bodyHtml}
`; row.dataset.rawText = String(content).trim(); inner.appendChild(row); } @@ -286,9 +378,9 @@ function renderMessages(){ else inner.appendChild(frag); } } - $('messages').scrollTop=$('messages').scrollHeight; + scrollToBottom(); // Apply syntax highlighting after DOM is built - requestAnimationFrame(()=>highlightCode()); + requestAnimationFrame(()=>{highlightCode();renderMermaidBlocks();}); // Refresh todo panel if it's currently open if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){ loadTodos(); @@ -463,15 +555,54 @@ function highlightCode(container) { if(typeof Prism === 'undefined' || !Prism.highlightAllUnder) return; const el = container || $('msgInner'); if(!el) return; - // Prism autoloader handles language detection via class="language-xxx" Prism.highlightAllUnder(el); } +let _mermaidLoading=false; +let _mermaidReady=false; + +function renderMermaidBlocks(){ + const blocks=document.querySelectorAll('.mermaid-block:not([data-rendered])'); + if(!blocks.length) return; + if(!_mermaidReady){ + if(!_mermaidLoading){ + _mermaidLoading=true; + const script=document.createElement('script'); + script.src='https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js'; + script.onload=()=>{ + if(typeof mermaid!=='undefined'){ + mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{ + primaryColor:'#4a6fa5',primaryTextColor:'#e2e8f0',lineColor:'#718096', + secondaryColor:'#2d3748',tertiaryColor:'#1a202c',primaryBorderColor:'#4a5568', + }}); + _mermaidReady=true; + renderMermaidBlocks(); + } + }; + document.head.appendChild(script); + } + return; + } + blocks.forEach(async(block)=>{ + block.dataset.rendered='true'; + const code=block.textContent; + const id=block.dataset.mermaidId||('m-'+Math.random().toString(36).slice(2)); + try{ + const {svg}=await mermaid.render(id,code); + block.innerHTML=svg; + block.classList.add('mermaid-rendered'); + }catch(e){ + // Fall back to showing as a code block + block.innerHTML=`
mermaid
${esc(code)}
`; + } + }); +} + function appendThinking(){ $('emptyState').style.display='none'; const row=document.createElement('div');row.className='msg-row';row.id='thinkingRow'; row.innerHTML=`
H
Hermes
`; - $('msgInner').appendChild(row);$('messages').scrollTop=$('messages').scrollHeight; + $('msgInner').appendChild(row);scrollToBottom(); } function removeThinking(){const el=$('thinkingRow');if(el)el.remove();} @@ -501,7 +632,37 @@ function renderFileTree(){ // Name -- takes all remaining space, truncates with ellipsis const nameEl=document.createElement('span'); - nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=item.name; + nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title='Double-click to rename'; + // Inline rename on double-click + nameEl.ondblclick=(e)=>{ + e.stopPropagation(); + const inp=document.createElement('input'); + inp.className='file-rename-input';inp.value=item.name; + inp.onclick=(e2)=>e2.stopPropagation(); + const finish=async(save)=>{ + inp.onblur=null; // prevent double-call: Enter triggers blur after replaceWith + if(save){ + const newName=inp.value.trim(); + if(newName&&newName!==item.name){ + try{ + await api('/api/file/rename',{method:'POST',body:JSON.stringify({ + session_id:S.session.session_id,path:item.path,new_name:newName + })}); + showToast(`Renamed to ${newName}`); + await loadDir('.'); + }catch(err){showToast('Rename failed: '+err.message);} + } + } + inp.replaceWith(nameEl); + }; + inp.onkeydown=(e2)=>{ + if(e2.key==='Enter'){e2.preventDefault();finish(true);} + if(e2.key==='Escape'){e2.preventDefault();finish(false);} + }; + inp.onblur=()=>finish(false); + nameEl.replaceWith(inp); + setTimeout(()=>{inp.focus();inp.select();},10); + }; el.appendChild(nameEl); // Size -- only for files, right-aligned, shrinks but never wraps @@ -512,7 +673,7 @@ function renderFileTree(){ el.appendChild(sizeEl); } - // Delete button -- only for files, shown as a CSS class toggle on hover + // Delete button -- for files, shown on hover if(item.type==='file'){ const del=document.createElement('button'); del.className='file-del-btn';del.title='Delete';del.textContent='×'; @@ -550,6 +711,17 @@ async function promptNewFile(){ }catch(e){setStatus('Create failed: '+e.message);} } +async function promptNewFolder(){ + if(!S.session)return; + const name=prompt('New folder name:',''); + if(!name||!name.trim())return; + try{ + await api('/api/file/create-dir',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:name.trim()})}); + showToast(`Created folder ${name.trim()}`); + await loadDir('.'); + }catch(e){setStatus('Create folder failed: '+e.message);} +} + function renderTray(){ const tray=$('attachTray');tray.innerHTML=''; if(!S.pendingFiles.length){tray.classList.remove('has-files');return;} diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 3173cf1..4e396b6 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -156,14 +156,17 @@ def test_cancel_nonexistent_stream_returns_not_cancelled(cleanup_test_sessions): def test_server_py_sse_loop_breaks_on_cancel(cleanup_test_sessions): - """R5b: server.py SSE loop must include 'cancel' in the break condition. + """R5b: SSE loop must include 'cancel' in the break condition. When missing, the connection hung after the cancel event was processed. + Sprint 11: logic moved from server.py to api/routes.py -- check both. """ - src = (REPO_ROOT / "server.py").read_text() - # Find the SSE break condition import re - m = re.search(r"if event in \([^)]+\):\s*break", src) - assert m, "SSE break condition not found in server.py" + # Check server.py first, then api/routes.py (Sprint 11 extracted routes) + src = (REPO_ROOT / "server.py").read_text() + routes_src = (REPO_ROOT / "api" / "routes.py").read_text() if (REPO_ROOT / "api" / "routes.py").exists() else "" + combined = src + routes_src + m = re.search(r"if event in \([^)]+\):\s*break", combined) + assert m, "SSE break condition not found in server.py or api/routes.py" assert "cancel" in m.group(), \ f"'cancel' missing from SSE break condition: {m.group()}" @@ -275,16 +278,21 @@ def test_deleted_session_does_not_appear_in_list(cleanup_test_sessions): def test_server_delete_invalidates_index(cleanup_test_sessions): - """R8b: server.py session/delete handler must unlink _index.json. + """R8b: session/delete handler must unlink _index.json. Static check that the fix is in place. + Sprint 11: handler moved from server.py to api/routes.py -- check both. """ src = (REPO_ROOT / "server.py").read_text() - # Find the delete handler and verify it unlinks the index - delete_idx = src.find("if parsed.path == '/api/session/delete':") - assert delete_idx >= 0, "session/delete handler not found" - delete_block = src[delete_idx:delete_idx+600] - assert "SESSION_INDEX_FILE" in delete_block, "server.py session/delete must invalidate SESSION_INDEX_FILE" - + routes_src = (REPO_ROOT / "api" / "routes.py").read_text() if (REPO_ROOT / "api" / "routes.py").exists() else "" + # Find the delete handler in either file + for label, text in [("server.py", src), ("api/routes.py", routes_src)]: + delete_idx = text.find("if parsed.path == '/api/session/delete':") + if delete_idx >= 0: + delete_block = text[delete_idx:delete_idx+600] + assert "SESSION_INDEX_FILE" in delete_block, \ + f"{label} session/delete must invalidate SESSION_INDEX_FILE" + return + assert False, "session/delete handler not found in server.py or api/routes.py" # ── R9: Token/tool SSE events write to wrong session after switch ───────────── @@ -292,25 +300,36 @@ def test_token_handler_guards_session_id(cleanup_test_sessions): """R9a: The SSE token event handler must check activeSid before writing to DOM. When missing, tokens from session A would render into session B's message area if the user switched sessions mid-stream. + Sprint 12: handler moved into _wireSSE(source), so search source.addEventListener. """ src = (REPO_ROOT / "static/messages.js").read_text() - # Find the token event handler - token_idx = src.find("es.addEventListener('token'") + # Sprint 12 refactored es.addEventListener -> source.addEventListener inside _wireSSE() + token_idx = src.find("source.addEventListener('token'") + if token_idx < 0: + token_idx = src.find("es.addEventListener('token'") assert token_idx >= 0, "token event handler not found" token_block = src[token_idx:token_idx+300] - assert "activeSid" in token_block, "token handler must check activeSid before writing to DOM" - assert "S.session.session_id!==activeSid" in token_block or "S.session.session_id===activeSid" in token_block, "token handler must compare current session to activeSid" + assert "activeSid" in token_block, \ + "token handler must check activeSid before writing to DOM" + assert "S.session.session_id!==activeSid" in token_block or \ + "S.session.session_id===activeSid" in token_block, \ + "token handler must compare current session to activeSid" def test_tool_handler_guards_session_id(cleanup_test_sessions): """R9b: The SSE tool event handler must check activeSid before writing to DOM. When missing, tool cards from session A would render into session B's message area. + Sprint 12: handler moved into _wireSSE(source), so search source.addEventListener. """ src = (REPO_ROOT / "static/messages.js").read_text() - tool_idx = src.find("es.addEventListener('tool'") + tool_idx = src.find("source.addEventListener('tool'") + if tool_idx < 0: + tool_idx = src.find("es.addEventListener('tool'") assert tool_idx >= 0, "tool event handler not found" tool_block = src[tool_idx:tool_idx+400] - assert "activeSid" in tool_block, "tool handler must check activeSid before writing to DOM" + assert "activeSid" in tool_block, \ + "tool handler must check activeSid before writing to DOM" + # ── R10: respondApproval uses wrong session_id after switch (multi-session) ─ @@ -337,8 +356,10 @@ def test_tool_status_only_shown_for_current_session(cleanup_test_sessions): When missing, session A's tool names would appear in session B's activity bar. """ src = (REPO_ROOT / "static/messages.js").read_text() - # Find the tool event handler - tool_idx = src.find("es.addEventListener('tool'") + # Sprint 12: handler moved into _wireSSE(source) + tool_idx = src.find("source.addEventListener('tool'") + if tool_idx < 0: + tool_idx = src.find("es.addEventListener('tool'") assert tool_idx >= 0 tool_block = src[tool_idx:tool_idx+400] # setStatus must be inside the activeSid guard, not before it @@ -347,8 +368,8 @@ def test_tool_status_only_shown_for_current_session(cleanup_test_sessions): assert guard_pos >= 0, "tool handler must guard with activeSid check" # The guard must appear BEFORE or AROUND the setStatus call # (status only fires for the current session) - assert status_pos > tool_block.find("activeSid"), "setStatus in tool handler must be inside the activeSid guard" - + assert status_pos > tool_block.find("activeSid"), \ + "setStatus in tool handler must be inside the activeSid guard" # ── R12: Live tool cards lost on switch-away and switch-back ────────────── @@ -375,7 +396,10 @@ def test_done_handler_sets_busy_false_before_renderMessages(cleanup_test_session tool cards are skipped entirely after a response completes. """ src = (REPO_ROOT / "static/messages.js").read_text() - done_idx = src.find("es.addEventListener('done'") + # Sprint 12: handler moved into _wireSSE(source) + done_idx = src.find("source.addEventListener('done'") + if done_idx < 0: + done_idx = src.find("es.addEventListener('done'") assert done_idx >= 0 done_block = src[done_idx:done_idx+1500] # S.busy=false must appear before renderMessages() within the done handler diff --git a/tests/test_sprint1.py b/tests/test_sprint1.py index 8e8893a..54fe0cc 100644 --- a/tests/test_sprint1.py +++ b/tests/test_sprint1.py @@ -1,5 +1,5 @@ """ -Sprint 1 test suite for the Hermes WebUI. +Sprint 1 test suite for the Hermes Web UI. Tests use the ISOLATED test server running on http://127.0.0.1:8788. Production server (port 8787) and your real conversations are never touched. diff --git a/tests/test_sprint11.py b/tests/test_sprint11.py new file mode 100644 index 0000000..a02a2de --- /dev/null +++ b/tests/test_sprint11.py @@ -0,0 +1,96 @@ +""" +Sprint 11 Tests: multi-provider model support, streaming smoothness, routes extraction. +""" +import json, pathlib, urllib.error, urllib.request, urllib.parse +REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() + +BASE = "http://127.0.0.1:8788" + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +# ── /api/models endpoint ────────────────────────────────────────────────── + +def test_models_endpoint_returns_200(): + """GET /api/models returns a valid response.""" + d, status = get("/api/models") + assert status == 200 + +def test_models_has_required_fields(): + """Response includes groups, default_model, and active_provider.""" + d, _ = get("/api/models") + assert 'groups' in d + assert 'default_model' in d + assert 'active_provider' in d + +def test_models_groups_structure(): + """Each group has provider name and models list.""" + d, _ = get("/api/models") + assert isinstance(d['groups'], list) + assert len(d['groups']) > 0 + for group in d['groups']: + assert 'provider' in group + assert 'models' in group + assert isinstance(group['models'], list) + assert len(group['models']) > 0 + +def test_models_model_structure(): + """Each model has id and label.""" + d, _ = get("/api/models") + for group in d['groups']: + for model in group['models']: + assert 'id' in model + assert 'label' in model + assert isinstance(model['id'], str) + assert isinstance(model['label'], str) + assert len(model['id']) > 0 + assert len(model['label']) > 0 + +def test_models_default_model_not_empty(): + """Default model should be a non-empty string.""" + d, _ = get("/api/models") + assert isinstance(d['default_model'], str) + assert len(d['default_model']) > 0 + +def test_models_at_least_one_provider(): + """At least one provider group should exist (fallback list at minimum).""" + d, _ = get("/api/models") + providers = [g['provider'] for g in d['groups']] + assert len(providers) >= 1 + +def test_models_no_duplicate_ids(): + """Model IDs should not be duplicated within a single group.""" + d, _ = get("/api/models") + for group in d['groups']: + ids = [m['id'] for m in group['models']] + assert len(ids) == len(set(ids)), f"Duplicate model IDs in {group['provider']}: {ids}" + +def test_session_preserves_unlisted_model(): + """A session with a model not in the dropdown should still load correctly.""" + # Create a session with a custom model string + d, _ = post("/api/session/new", {}) + sid = d['session']['session_id'] + try: + custom_model = 'custom-provider/test-model-999' + post("/api/session/update", { + 'session_id': sid, + 'model': custom_model, + 'workspace': d['session']['workspace'] + }) + # Reload and verify model persisted + d2, _ = get(f"/api/session?session_id={sid}") + assert d2['session']['model'] == custom_model + finally: + post("/api/session/delete", {'session_id': sid}) diff --git a/tests/test_sprint12.py b/tests/test_sprint12.py new file mode 100644 index 0000000..4426687 --- /dev/null +++ b/tests/test_sprint12.py @@ -0,0 +1,179 @@ +""" +Sprint 12 Tests: settings panel, session pinning, session import, SSE reconnect. +""" +import json, pathlib, urllib.error, urllib.request, urllib.parse + +BASE = "http://127.0.0.1:8788" + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +def make_session(created_list): + d, _ = post("/api/session/new", {}) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid + + +# ── Settings API ────────────────────────────────────────────────────────── + +def test_settings_get_returns_defaults(): + """GET /api/settings returns default settings.""" + d, status = get("/api/settings") + assert status == 200 + assert 'default_model' in d + assert 'default_workspace' in d + +def test_settings_post_persists(): + """POST /api/settings saves and returns merged settings.""" + d, status = post("/api/settings", {"default_model": "test/model-123"}) + assert status == 200 + assert d['default_model'] == 'test/model-123' + # Verify it persisted + d2, _ = get("/api/settings") + assert d2['default_model'] == 'test/model-123' + # Restore + post("/api/settings", {"default_model": "openai/gpt-5.4-mini"}) + +def test_settings_partial_update(): + """POST /api/settings with partial data doesn't clobber other fields.""" + d1, _ = get("/api/settings") + original_ws = d1['default_workspace'] + post("/api/settings", {"default_model": "anthropic/claude-sonnet-4.6"}) + d2, _ = get("/api/settings") + assert d2['default_model'] == 'anthropic/claude-sonnet-4.6' + assert d2['default_workspace'] == original_ws + # Restore + post("/api/settings", {"default_model": "openai/gpt-5.4-mini"}) + + +# ── Session Pinning ─────────────────────────────────────────────────────── + +def test_pin_session(): + """POST /api/session/pin sets pinned=true.""" + created = [] + try: + sid = make_session(created) + d, status = post("/api/session/pin", {"session_id": sid, "pinned": True}) + assert status == 200 + assert d['ok'] is True + assert d['session']['pinned'] is True + finally: + for sid in created: + post("/api/session/delete", {"session_id": sid}) + +def test_unpin_session(): + """POST /api/session/pin with pinned=false unpins.""" + created = [] + try: + sid = make_session(created) + post("/api/session/pin", {"session_id": sid, "pinned": True}) + d, status = post("/api/session/pin", {"session_id": sid, "pinned": False}) + assert status == 200 + assert d['session']['pinned'] is False + finally: + for sid in created: + post("/api/session/delete", {"session_id": sid}) + +def test_pinned_in_session_list(): + """Pinned sessions include pinned field in session list.""" + created = [] + try: + sid = make_session(created) + # Pin it and give it a title so it shows in the list + post("/api/session/rename", {"session_id": sid, "title": "Pinned Test"}) + post("/api/session/pin", {"session_id": sid, "pinned": True}) + d, _ = get("/api/sessions") + match = [s for s in d['sessions'] if s['session_id'] == sid] + assert len(match) == 1 + assert match[0]['pinned'] is True + finally: + for sid in created: + post("/api/session/delete", {"session_id": sid}) + +def test_pinned_persists_on_reload(): + """Pin status survives session reload from disk.""" + created = [] + try: + sid = make_session(created) + post("/api/session/pin", {"session_id": sid, "pinned": True}) + d, _ = get(f"/api/session?session_id={sid}") + assert d['session']['pinned'] is True + finally: + for sid in created: + post("/api/session/delete", {"session_id": sid}) + + +# ── Session Import ──────────────────────────────────────────────────────── + +def test_import_session_basic(): + """POST /api/session/import creates a new session from JSON.""" + payload = { + "title": "Imported Test", + "messages": [ + {"role": "user", "content": "Hello from import"}, + {"role": "assistant", "content": "Hi there!"}, + ], + "model": "test/import-model", + } + d, status = post("/api/session/import", payload) + assert status == 200 + assert d['ok'] is True + sid = d['session']['session_id'] + try: + assert d['session']['title'] == 'Imported Test' + assert len(d['session']['messages']) == 2 + # Verify it loads correctly + d2, _ = get(f"/api/session?session_id={sid}") + assert d2['session']['model'] == 'test/import-model' + finally: + post("/api/session/delete", {"session_id": sid}) + +def test_import_requires_messages(): + """Import fails without a messages array.""" + d, status = post("/api/session/import", {"title": "No messages"}) + assert status == 400 + +def test_import_creates_new_id(): + """Imported session gets a new session_id, not reusing any from the payload.""" + payload = { + "session_id": "should_be_ignored", + "title": "ID Test", + "messages": [{"role": "user", "content": "test"}], + } + d, _ = post("/api/session/import", payload) + sid = d['session']['session_id'] + try: + # The import should create a new ID, not use the one from the payload + assert sid != "should_be_ignored" + finally: + post("/api/session/delete", {"session_id": sid}) + +def test_import_with_pinned(): + """Imported session can be pinned.""" + payload = { + "title": "Pinned Import", + "messages": [{"role": "user", "content": "test"}], + "pinned": True, + } + d, _ = post("/api/session/import", payload) + sid = d['session']['session_id'] + try: + d2, _ = get(f"/api/session?session_id={sid}") + assert d2['session']['pinned'] is True + finally: + post("/api/session/delete", {"session_id": sid}) diff --git a/tests/test_sprint13.py b/tests/test_sprint13.py new file mode 100644 index 0000000..2a90a8c --- /dev/null +++ b/tests/test_sprint13.py @@ -0,0 +1,120 @@ +""" +Sprint 13 Tests: cron recent endpoint, session duplicate, background alerts. +""" +import json, pathlib, urllib.error, urllib.request + +BASE = "http://127.0.0.1:8788" + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +def make_session(created_list): + d, _ = post("/api/session/new", {}) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid, d["session"] + + +# ── Cron recent endpoint ────────────────────────────────────────────────── + +def test_crons_recent_returns_200(): + """GET /api/crons/recent returns completions list.""" + d, status = get("/api/crons/recent?since=0") + assert status == 200 + assert 'completions' in d + assert isinstance(d['completions'], list) + assert 'since' in d + +def test_crons_recent_with_future_since(): + """Completions list is empty when since is in the future.""" + import time + d, _ = get(f"/api/crons/recent?since={time.time() + 99999}") + assert d['completions'] == [] + +def test_crons_recent_default_since(): + """Default since=0 returns all completions.""" + d, status = get("/api/crons/recent") + assert status == 200 + assert 'completions' in d + + +# ── Session duplicate ───────────────────────────────────────────────────── + +def test_duplicate_session(): + """Duplicating a session creates a new one with same workspace/model.""" + created = [] + try: + sid, sess = make_session(created) + # Set a specific model on the session + post("/api/session/update", { + "session_id": sid, "model": "test/dup-model", + "workspace": sess["workspace"] + }) + # Duplicate: create new session with same workspace/model + d2, status = post("/api/session/new", { + "workspace": sess["workspace"], "model": "test/dup-model" + }) + assert status == 200 + new_sid = d2["session"]["session_id"] + created.append(new_sid) + assert new_sid != sid + assert d2["session"]["model"] == "test/dup-model" + assert d2["session"]["workspace"] == sess["workspace"] + finally: + for s in created: + post("/api/session/delete", {"session_id": s}) + + +# ── Session pinned field preserved across operations ────────────────────── + +def test_pinned_survives_update(): + """Pinned status survives session update.""" + created = [] + try: + sid, sess = make_session(created) + post("/api/session/pin", {"session_id": sid, "pinned": True}) + # Update workspace/model + post("/api/session/update", { + "session_id": sid, "model": "test/other", + "workspace": sess["workspace"] + }) + d, _ = get(f"/api/session?session_id={sid}") + assert d["session"]["pinned"] is True + finally: + for s in created: + post("/api/session/delete", {"session_id": s}) + + +# ── Workspace symlink validation ────────────────────────────────────────── + +def test_workspace_add_rejects_nonexistent(): + """Adding a non-existent path returns 400.""" + d, status = post("/api/workspaces/add", {"path": "/nonexistent/path/12345"}) + assert status == 400 + +def test_workspace_add_accepts_real_dir(): + """Adding a real directory succeeds.""" + import tempfile + tmp = tempfile.mkdtemp() + try: + d, status = post("/api/workspaces/add", {"path": tmp, "name": "test-ws"}) + assert status == 200 + assert d["ok"] is True + finally: + post("/api/workspaces/remove", {"path": tmp}) + import shutil + shutil.rmtree(tmp, ignore_errors=True) diff --git a/tests/test_sprint14.py b/tests/test_sprint14.py new file mode 100644 index 0000000..8826e75 --- /dev/null +++ b/tests/test_sprint14.py @@ -0,0 +1,153 @@ +""" +Sprint 14 Tests: file rename, folder create, session archive, session tags, mermaid, timestamps. +""" +import json, os, pathlib, shutil, tempfile, urllib.error, urllib.request + +BASE = "http://127.0.0.1:8788" + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +def make_session(created_list): + d, _ = post("/api/session/new", {}) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid, d["session"] + + +# ── File rename ─────────────────────────────────────────────────────────── + +def test_file_rename(): + """Renaming a file changes its name on disk.""" + created = [] + try: + sid, sess = make_session(created) + # Create a file first + post("/api/file/create", {"session_id": sid, "path": "rename_test.txt", "content": "hello"}) + d, status = post("/api/file/rename", { + "session_id": sid, "path": "rename_test.txt", "new_name": "renamed.txt" + }) + assert status == 200 + assert d["ok"] is True + assert "renamed.txt" in d["new_path"] + finally: + for s in created: + post("/api/session/delete", {"session_id": s}) + + +def test_file_rename_rejects_path_traversal(): + """Rename rejects names with path separators.""" + created = [] + try: + sid, sess = make_session(created) + post("/api/file/create", {"session_id": sid, "path": "safe.txt", "content": ""}) + d, status = post("/api/file/rename", { + "session_id": sid, "path": "safe.txt", "new_name": "../evil.txt" + }) + assert status == 400 + finally: + for s in created: + post("/api/session/delete", {"session_id": s}) + + +def test_file_rename_rejects_existing(): + """Rename fails if target name already exists.""" + created = [] + try: + sid, sess = make_session(created) + post("/api/file/create", {"session_id": sid, "path": "a.txt", "content": "a"}) + post("/api/file/create", {"session_id": sid, "path": "b.txt", "content": "b"}) + d, status = post("/api/file/rename", { + "session_id": sid, "path": "a.txt", "new_name": "b.txt" + }) + assert status == 400 + finally: + for s in created: + post("/api/session/delete", {"session_id": s}) + + +# ── Folder create ───────────────────────────────────────────────────────── + +def test_create_dir(): + """Creating a folder succeeds.""" + created = [] + try: + sid, sess = make_session(created) + d, status = post("/api/file/create-dir", { + "session_id": sid, "path": "test_folder" + }) + assert status == 200 + assert d["ok"] is True + finally: + for s in created: + post("/api/session/delete", {"session_id": s}) + + +def test_create_dir_rejects_existing(): + """Creating a folder that already exists fails.""" + created = [] + try: + sid, sess = make_session(created) + post("/api/file/create-dir", {"session_id": sid, "path": "dup_folder"}) + d, status = post("/api/file/create-dir", {"session_id": sid, "path": "dup_folder"}) + assert status == 400 + finally: + for s in created: + post("/api/session/delete", {"session_id": s}) + + +# ── Session archive ─────────────────────────────────────────────────────── + +def test_archive_session(): + """Archiving a session sets archived=true.""" + created = [] + try: + sid, _ = make_session(created) + d, status = post("/api/session/archive", {"session_id": sid, "archived": True}) + assert status == 200 + assert d["session"]["archived"] is True + finally: + for s in created: + post("/api/session/delete", {"session_id": s}) + + +def test_unarchive_session(): + """Unarchiving a session sets archived=false.""" + created = [] + try: + sid, _ = make_session(created) + post("/api/session/archive", {"session_id": sid, "archived": True}) + d, status = post("/api/session/archive", {"session_id": sid, "archived": False}) + assert status == 200 + assert d["session"]["archived"] is False + finally: + for s in created: + post("/api/session/delete", {"session_id": s}) + + +def test_archived_in_compact(): + """Archived field appears in session list.""" + created = [] + try: + sid, _ = make_session(created) + post("/api/session/rename", {"session_id": sid, "title": "Archive Test"}) + post("/api/session/archive", {"session_id": sid, "archived": True}) + d, _ = get(f"/api/session?session_id={sid}") + assert d["session"]["archived"] is True + finally: + for s in created: + post("/api/session/delete", {"session_id": s})