Files
webui/static/sessions.js
nesquena-hermes 711bb5a6c9 feat: real-time gateway session sync (Phase 1) (#274)
* feat: add real-time gateway session sync (Phase 1)

- Add gateway_watcher.py: background daemon polling state.db every 5s
  for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import

Addresses GitHub issue #272

* fix: SSE event name mismatch, TLS attribute, remove PLAN.md

- Fix critical SSE bug: frontend listened for 'gateway_session_update'
  but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
  the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
  does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: test isolation and slow-consumer sentinel in gateway sync

tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
  HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
  so state.db was written to a double-nested path the server never read.
  Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
  TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
  connection stays valid for _remove_test_sessions() in the finally block.
  Previously the closed conn caused ProgrammingError in finally (swallowed
  by bare except), leaving ghost sessions in state.db on test failure.

api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
  and is removed from _subscribers, now puts a None sentinel into it so the
  SSE handler unblocks and closes the connection, letting EventSource
  auto-reconnect. Without this the connection stayed open but received no
  further events.

* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest

The gateway sync tests write directly to state.db and must use the same
path the test server reads from.  Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.

tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
  os.environ (via setdefault) so gateway tests can read it reliably.
  Using setdefault preserves any explicit override the caller may pass.

tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
  (now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
  Remove the workaround that tried to snapshot HERMES_HOME at import time.

Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:53:12 -07:00

739 lines
32 KiB
JavaScript

// ── Session action icons (SVG, monochrome, inherit currentColor) ──
const ICONS={
pin:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><polygon points="8,1.5 9.8,5.8 14.5,6.2 11,9.4 12,14 8,11.5 4,14 5,9.4 1.5,6.2 6.2,5.8"/></svg>',
unpin:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><polygon points="8,2 9.8,6.2 14.2,6.2 10.7,9.2 12,13.8 8,11 4,13.8 5.3,9.2 1.8,6.2 6.2,6.2"/></svg>',
folder:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M2 4.5h4l1.5 1.5H14v7H2z"/></svg>',
archive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><line x1="6" y1="8.5" x2="10" y2="8.5"/></svg>',
unarchive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><polyline points="6.5,7 8,5.5 9.5,7"/></svg>',
dup:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="4.5" y="4.5" width="8.5" height="8.5" rx="1.5"/><path d="M3 11.5V3h8.5"/></svg>',
trash:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>',
more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
};
let _sessionActionMenu = null;
let _sessionActionAnchor = null;
let _sessionActionSessionId = null;
function closeSessionActionMenu(){
if(_sessionActionMenu){
_sessionActionMenu.remove();
_sessionActionMenu = null;
}
if(_sessionActionAnchor){
_sessionActionAnchor.classList.remove('active');
const row=_sessionActionAnchor.closest('.session-item');
if(row) row.classList.remove('menu-open');
_sessionActionAnchor = null;
}
_sessionActionSessionId = null;
}
function _positionSessionActionMenu(anchorEl){
if(!_sessionActionMenu || !anchorEl) return;
const rect=anchorEl.getBoundingClientRect();
const menuW=Math.min(280, Math.max(220, _sessionActionMenu.scrollWidth || 220));
let left=rect.right-menuW;
if(left<8) left=8;
if(left+menuW>window.innerWidth-8) left=window.innerWidth-menuW-8;
_sessionActionMenu.style.left=left+'px';
_sessionActionMenu.style.top='8px';
const menuH=_sessionActionMenu.offsetHeight || 0;
let top=rect.bottom+6;
if(top+menuH>window.innerHeight-8 && rect.top>menuH+12){
top=rect.top-menuH-6;
}
if(top<8) top=8;
_sessionActionMenu.style.top=top+'px';
}
function _buildSessionAction(label, meta, icon, onSelect, extraClass=''){
const opt=document.createElement('button');
opt.type='button';
opt.className='ws-opt session-action-opt'+(extraClass?` ${extraClass}`:'');
opt.innerHTML=
`<span class="ws-opt-action">`
+ `<span class="ws-opt-icon">${icon}</span>`
+ `<span class="session-action-copy">`
+ `<span class="ws-opt-name">${esc(label)}</span>`
+ (meta?`<span class="session-action-meta">${esc(meta)}</span>`:'')
+ `</span>`
+ `</span>`;
opt.onclick=async(e)=>{
e.preventDefault();
e.stopPropagation();
await onSelect();
};
return opt;
}
function _openSessionActionMenu(session, anchorEl){
if(_sessionActionMenu && _sessionActionSessionId===session.session_id && _sessionActionAnchor===anchorEl){
closeSessionActionMenu();
return;
}
closeSessionActionMenu();
const menu=document.createElement('div');
menu.className='session-action-menu open';
menu.appendChild(_buildSessionAction(
session.pinned?'Unpin conversation':'Pin conversation',
session.pinned?'Remove from the pinned section':'Keep this conversation at the top',
session.pinned?ICONS.pin:ICONS.unpin,
async()=>{
closeSessionActionMenu();
const newPinned=!session.pinned;
try{
await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})});
session.pinned=newPinned;
if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned;
renderSessionList();
}catch(err){showToast('Pin failed: '+err.message);}
},
session.pinned?'is-active':''
));
menu.appendChild(_buildSessionAction(
'Move to project',
session.project_id?'Change which project this conversation belongs to':'Assign this conversation to a project',
ICONS.folder,
async()=>{
closeSessionActionMenu();
_showProjectPicker(session, anchorEl);
}
));
menu.appendChild(_buildSessionAction(
session.archived?'Restore conversation':'Archive conversation',
session.archived?'Bring this conversation back into the main list':'Hide this conversation until archived is shown',
session.archived?ICONS.unarchive:ICONS.archive,
async()=>{
closeSessionActionMenu();
try{
await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})});
session.archived=!session.archived;
if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived;
await renderSessionList();
showToast(session.archived?'Session archived':'Session restored');
}catch(err){showToast('Archive failed: '+err.message);}
}
));
menu.appendChild(_buildSessionAction(
'Duplicate conversation',
'Create a copy with the same workspace and model',
ICONS.dup,
async()=>{
closeSessionActionMenu();
try{
const res=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:session.workspace,model:session.model})});
if(res.session){
await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:res.session.session_id,title:(session.title||'Untitled')+' (copy)'})});
await loadSession(res.session.session_id);
await renderSessionList();
showToast('Session duplicated');
}
}catch(err){showToast('Duplicate failed: '+err.message);}
}
));
menu.appendChild(_buildSessionAction(
'Delete conversation',
'Permanently remove this conversation',
ICONS.trash,
async()=>{
closeSessionActionMenu();
await deleteSession(session.session_id);
},
'danger'
));
document.body.appendChild(menu);
_sessionActionMenu = menu;
_sessionActionAnchor = anchorEl;
_sessionActionSessionId = session.session_id;
anchorEl.classList.add('active');
const row=anchorEl.closest('.session-item');
if(row) row.classList.add('menu-open');
_positionSessionActionMenu(anchorEl);
}
document.addEventListener('click',e=>{
if(!_sessionActionMenu) return;
if(_sessionActionMenu.contains(e.target)) return;
if(_sessionActionAnchor && _sessionActionAnchor.contains(e.target)) return;
closeSessionActionMenu();
});
document.addEventListener('scroll',e=>{
if(!_sessionActionMenu) return;
if(_sessionActionMenu.contains(e.target)) return;
closeSessionActionMenu();
}, true);
document.addEventListener('keydown',e=>{
if(e.key==='Escape' && _sessionActionMenu) closeSessionActionMenu();
});
window.addEventListener('resize',()=>{
if(_sessionActionMenu && _sessionActionAnchor) _positionSessionActionMenu(_sessionActionAnchor);
});
async function newSession(flash){
MSG_QUEUE.length=0;updateQueueBadge();
S.toolCalls=[];
clearLiveToolCards();
// Use profile default workspace for new sessions after a profile switch (one-shot),
// otherwise inherit from the current session (or let server pick the default)
const inheritWs=S._profileDefaultWorkspace||(S.session?S.session.workspace:null);
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
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((window._botName||'Hermes')+' is thinking\u2026');
startApprovalPolling(sid);
}else{
MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session
S.messages=data.session.messages||[];
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
// Reset per-session visual state: the viewed session is idle even if another
// session's stream is still running in the background.
// We directly update the DOM instead of calling setBusy(false), because
// setBusy(false) drains MSG_QUEUE which we don't want here.
S.busy=false;
S.activeStreamId=null;
$('btnSend').disabled=false;
$('btnSend').style.opacity='1';
const _dots=$('activityDots');if(_dots)_dots.style.display='none';
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
setStatus('');
clearLiveToolCards();
syncTopbar();await loadDir('.');renderMessages();highlightCode();
}
}
let _allSessions = []; // cached for search filter
let _renamingSid = null; // session_id currently being renamed (blocks list re-renders)
let _showArchived = false; // toggle to show archived sessions
let _allProjects = []; // cached project list
let _activeProject = null; // project_id filter (null = show all)
let _showAllProfiles = false; // false = filter to active profile only
async function renderSessionList(){
try{
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
const [sessData, projData] = await Promise.all([
api('/api/sessions'),
api('/api/projects'),
]);
_allSessions = sessData.sessions||[];
_allProjects = projData.projects||[];
renderSessionListFromCache(); // no-ops if rename is in progress
}catch(e){console.warn('renderSessionList',e);}
}
// ── Gateway session SSE (real-time sync for agent sessions) ──
let _gatewaySSE = null;
function startGatewaySSE(){
stopGatewaySSE();
if(!window._showCliSessions) return;
try{
_gatewaySSE = new EventSource('/api/sessions/gateway/stream');
_gatewaySSE.addEventListener('sessions_changed', (ev) => {
try{
const data = JSON.parse(ev.data);
if(data.sessions){
renderSessionList(); // re-fetch and re-render
}
}catch(e){ /* ignore parse errors */ }
});
_gatewaySSE.onerror = () => {
// EventSource auto-reconnects; no action needed
};
}catch(e){ /* SSE not available */ }
}
function stopGatewaySSE(){
if(_gatewaySSE){
_gatewaySSE.close();
_gatewaySSE = null;
}
}
let _searchDebounceTimer = null;
let _contentSearchResults = []; // results from /api/sessions/search content scan
function filterSessions(){
// Immediate client-side title filter (no flicker)
renderSessionListFromCache();
// Debounced content search via API for message text
const q = ($('sessionSearch').value || '').trim();
clearTimeout(_searchDebounceTimer);
if (!q) { _contentSearchResults = []; return; }
_searchDebounceTimer = setTimeout(async () => {
try {
const data = await api(`/api/sessions/search?q=${encodeURIComponent(q)}&content=1&depth=5`);
const titleIds = new Set(_allSessions.filter(s => (s.title||'Untitled').toLowerCase().includes(q.toLowerCase())).map(s=>s.session_id));
_contentSearchResults = (data.sessions||[]).filter(s => s.match_type === 'content' && !titleIds.has(s.session_id));
renderSessionListFromCache();
} catch(e) { /* ignore */ }
}, 350);
}
function renderSessionListFromCache(){
// Don't re-render while user is actively renaming a session (would destroy the input)
if(_renamingSid) return;
const q=($('sessionSearch').value||'').toLowerCase();
const titleMatches=q?_allSessions.filter(s=>(s.title||'Untitled').toLowerCase().includes(q)):_allSessions;
// Merge content matches (deduped): content matches appended after title matches
const titleIds=new Set(titleMatches.map(s=>s.session_id));
const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
// Filter by active profile (unless "All profiles" is toggled on)
// Server backfills profile='default' for legacy sessions, so every session has a profile.
// Show only sessions tagged to the active profile; 'All profiles' toggle overrides.
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>s.is_cli_session||s.profile===S.activeProfile);
// Filter by active project
const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered;
// Filter archived unless toggle is on
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
const archivedCount=projectFiltered.filter(s=>s.archived).length;
const list=$('sessionList');list.innerHTML='';
// Project filter bar (only when projects exist)
if(_allProjects.length>0){
const bar=document.createElement('div');
bar.className='project-bar';
// "All" chip
const allChip=document.createElement('span');
allChip.className='project-chip'+(!_activeProject?' active':'');
allChip.textContent='All';
allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();};
bar.appendChild(allChip);
// Project chips
for(const p of _allProjects){
const chip=document.createElement('span');
chip.className='project-chip'+(p.project_id===_activeProject?' active':'');
if(p.color){
const dot=document.createElement('span');
dot.className='color-dot';
dot.style.background=p.color;
chip.appendChild(dot);
}
const nameSpan=document.createElement('span');
nameSpan.textContent=p.name;
chip.appendChild(nameSpan);
chip.onclick=()=>{_activeProject=p.project_id;renderSessionListFromCache();};
chip.ondblclick=(e)=>{e.stopPropagation();_startProjectRename(p,chip);};
chip.oncontextmenu=(e)=>{e.preventDefault();_confirmDeleteProject(p);};
bar.appendChild(chip);
}
// Create button
const addBtn=document.createElement('button');
addBtn.className='project-create-btn';
addBtn.textContent='+';
addBtn.title='New project';
addBtn.onclick=(e)=>{e.stopPropagation();_startProjectCreate(bar,addBtn);};
bar.appendChild(addBtn);
list.appendChild(bar);
}
// Profile filter toggle (show sessions from other profiles)
const otherProfileCount=allMatched.filter(s=>s.profile&&s.profile!==S.activeProfile).length;
if(otherProfileCount>0&&!_showAllProfiles){
const pfToggle=document.createElement('div');
pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
pfToggle.textContent='Show '+otherProfileCount+' from other profiles';
pfToggle.onclick=()=>{_showAllProfiles=true;renderSessionListFromCache();};
list.appendChild(pfToggle);
} else if(_showAllProfiles&&otherProfileCount>0){
const pfToggle=document.createElement('div');
pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
pfToggle.textContent='Show active profile only';
pfToggle.onclick=()=>{_showAllProfiles=false;renderSessionListFromCache();};
list.appendChild(pfToggle);
}
// Show/hide archived toggle if there are archived sessions
if(archivedCount>0){
const toggle=document.createElement('div');
toggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
toggle.textContent=_showArchived?'Hide archived':'Show '+archivedCount+' archived';
toggle.onclick=()=>{_showArchived=!_showArchived;renderSessionListFromCache();};
list.appendChild(toggle);
}
// Empty state for active project filter
if(_activeProject&&sessions.length===0){
const empty=document.createElement('div');
empty.style.cssText='padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;';
empty.textContent='No sessions in this project yet.';
list.appendChild(empty);
}
// Separate pinned from unpinned
const pinned=sessions.filter(s=>s.pinned);
const unpinned=sessions.filter(s=>!s.pinned);
// Date grouping: Pinned / Today / Yesterday / Earlier
const now=Date.now();
const ONE_DAY=86400000;
// Collapse state persisted in localStorage
let _groupCollapsed={};
try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){}
const _saveCollapsed=()=>{try{localStorage.setItem('hermes-date-groups-collapsed',JSON.stringify(_groupCollapsed));}catch(e){}};
// Group sessions by date
const groups=[];
let curLabel=null,curItems=[];
if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true});
for(const s of unpinned){
const ts=(s.updated_at||s.created_at||0)*1000;
const label=ts>now-ONE_DAY?'Today':ts>now-2*ONE_DAY?'Yesterday':'Earlier';
if(label!==curLabel){
if(curItems.length) groups.push({label:curLabel,items:curItems});
curLabel=label;curItems=[s];
} else { curItems.push(s); }
}
if(curItems.length) groups.push({label:curLabel,items:curItems});
// Render groups with collapsible headers
for(const g of groups){
const wrapper=document.createElement('div');
wrapper.className='session-date-group';
const hdr=document.createElement('div');
hdr.className='session-date-header'+(g.isPinned?' pinned':'');
const caret=document.createElement('span');
caret.className='session-date-caret';
caret.textContent='\u25B8'; // right-pointing triangle
const label=document.createElement('span');
label.textContent=g.label;
hdr.appendChild(caret);hdr.appendChild(label);
const body=document.createElement('div');
body.className='session-date-body';
if(_groupCollapsed[g.label]){body.style.display='none';caret.classList.add('collapsed');}
hdr.onclick=()=>{
const isCollapsed=body.style.display==='none';
body.style.display=isCollapsed?'':'none';
caret.classList.toggle('collapsed',!isCollapsed);
_groupCollapsed[g.label]=!isCollapsed;
_saveCollapsed();
};
wrapper.appendChild(hdr);
for(const s of g.items){ body.appendChild(_renderOneSession(s)); }
wrapper.appendChild(body);
list.appendChild(wrapper);
}
// ── Render session items (extracted for group body use) ──
// Note: declared after the groups loop but available via function hoisting.
function _renderOneSession(s){
const el=document.createElement('div');
const isActive=S.session&&s.session_id===S.session.session_id;
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(s.is_cli_session?' cli-session':'');
if(s.source_tag) el.dataset.source=s.source_tag;
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
const rawTitle=s.title||'Untitled';
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
const cleanTitle=tags.length?rawTitle.replace(/#[\w-]+/g,'').trim():rawTitle;
const title=document.createElement('span');
title.className='session-title';
title.textContent=cleanTitle||'Untitled';
title.title='Double-click to rename';
// Append tag chips after the title text
for(const tag of tags){
const chip=document.createElement('span');
chip.className='session-tag';
chip.textContent=tag;
chip.title='Click to filter by '+tag;
chip.onclick=(e)=>{
e.stopPropagation();
const searchBox=$('sessionSearch');
if(searchBox){searchBox.value=tag;filterSessions();}
};
title.appendChild(chip);
}
// Rename: called directly when we confirm it's a double-click
const startRename=()=>{
_renamingSid = s.session_id;
const inp=document.createElement('input');
inp.className='session-title-input';
inp.value=s.title||'Untitled';
['click','mousedown','dblclick','pointerdown'].forEach(ev=>
inp.addEventListener(ev, e2=>e2.stopPropagation())
);
const finish=async(save)=>{
_renamingSid = null;
if(save){
const newTitle=inp.value.trim()||'Untitled';
title.textContent=newTitle;
s.title=newTitle;
if(S.session&&S.session.session_id===s.session_id){S.session.title=newTitle;syncTopbar();}
try{await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:s.session_id,title:newTitle})});}
catch(err){setStatus('Rename failed: '+err.message);}
}
inp.replaceWith(title);
// Allow list re-renders again after a short delay
setTimeout(()=>{ if(_renamingSid===null) renderSessionListFromCache(); },50);
};
inp.onkeydown=e2=>{
if(e2.key==='Enter'){e2.preventDefault();e2.stopPropagation();finish(true);}
if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);}
};
// onblur: cancel only -- no accidental saves
inp.onblur=()=>{ if(_renamingSid===s.session_id) finish(false); };
title.replaceWith(inp);
setTimeout(()=>{inp.focus();inp.select();},10);
};
// Pin indicator (inline, only when pinned — no space reserved otherwise)
if(s.pinned){
const pinInd=document.createElement('span');
pinInd.className='session-pin-indicator';
pinInd.innerHTML=ICONS.pin;
el.appendChild(pinInd);
}
// Project indicator: colored left border (active item keeps its own gold color)
if(s.project_id){
const proj=_allProjects.find(p=>p.project_id===s.project_id);
if(proj){
// project color shown via dot indicator, not left border
const dot=document.createElement('span');
dot.className='session-project-dot';
dot.style.background=proj.color||'var(--blue)';
dot.title=proj.name;
title.appendChild(dot);
}
}
el.appendChild(title);
const actions=document.createElement('div');
actions.className='session-actions';
const menuBtn=document.createElement('button');
menuBtn.type='button';
menuBtn.className='session-actions-trigger';
menuBtn.title='Conversation actions';
menuBtn.setAttribute('aria-haspopup','menu');
menuBtn.setAttribute('aria-label','Conversation actions');
menuBtn.innerHTML=ICONS.more;
menuBtn.onclick=(e)=>{
e.stopPropagation();
e.preventDefault();
_openSessionActionMenu(s, menuBtn);
};
actions.appendChild(menuBtn);
el.appendChild(actions);
// Use a click timer to distinguish single-click (navigate) from double-click (rename).
// This prevents loadSession from firing on the first click of a double-click,
// which would re-render the list and destroy the dblclick target before it fires.
let _clickTimer=null;
el.onclick=async(e)=>{
if(_renamingSid) return; // ignore while any rename is active
if(actions.contains(e.target)) return;
clearTimeout(_clickTimer);
_clickTimer=setTimeout(async()=>{
_clickTimer=null;
if(_renamingSid) return;
// For CLI sessions, import into WebUI store first (idempotent)
if(s.is_cli_session){
try{
await api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:s.session_id})});
}catch(e){ /* import failed -- fall through to read-only view */ }
}
await loadSession(s.session_id);renderSessionListFromCache();
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
}, 220);
};
el.ondblclick=async(e)=>{
e.stopPropagation();
e.preventDefault();
clearTimeout(_clickTimer); // cancel the pending single-click navigation
_clickTimer=null;
startRename();
};
return el;
}
}
async function deleteSession(sid){
const _delSess=await showConfirmDialog({title:'Delete conversation',message:'This cannot be undone.',confirmLabel:'Delete',danger:true,focusCancel:true});
if(!_delSess) 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=window._botName||'Hermes';
$('topbarMeta').textContent='Start a new conversation';
$('msgInner').innerHTML='';
$('emptyState').style.display='';
$('fileTree').innerHTML='';
}
}
showToast('Conversation deleted');
await renderSessionList();
}
// ── Project helpers ─────────────────────────────────────────────────────
const PROJECT_COLORS=['#7cb9ff','#f5c542','#e94560','#50c878','#c084fc','#fb923c','#67e8f9','#f472b6'];
function _showProjectPicker(session, anchorEl){
// Close any existing picker
document.querySelectorAll('.project-picker').forEach(p=>p.remove());
const picker=document.createElement('div');
picker.className='project-picker';
// "No project" option
const none=document.createElement('div');
none.className='project-picker-item'+(!session.project_id?' active':'');
none.textContent='No project';
none.onclick=async()=>{
picker.remove();
document.removeEventListener('click',close);
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})});
session.project_id=null;
renderSessionListFromCache();
showToast('Removed from project');
};
picker.appendChild(none);
// Project options
for(const p of _allProjects){
const item=document.createElement('div');
item.className='project-picker-item'+(session.project_id===p.project_id?' active':'');
if(p.color){
const dot=document.createElement('span');
dot.className='color-dot';
dot.style.cssText='width:6px;height:6px;border-radius:50%;background:'+p.color+';flex-shrink:0;';
item.appendChild(dot);
}
const name=document.createElement('span');
name.textContent=p.name;
item.appendChild(name);
item.onclick=async()=>{
picker.remove();
document.removeEventListener('click',close);
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:p.project_id})});
session.project_id=p.project_id;
renderSessionListFromCache();
showToast('Moved to '+p.name);
};
picker.appendChild(item);
}
// "+ New project" shortcut at the bottom
const createItem=document.createElement('div');
createItem.className='project-picker-item project-picker-create';
createItem.textContent='+ New project';
createItem.onclick=async()=>{
picker.remove();
document.removeEventListener('click',close);
// Prompt for name inline
const name=await showPromptDialog({title:'New project',message:'',placeholder:'Project name',confirmLabel:t('create')});
if(!name||!name.trim()) return;
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color})});
if(res.project){
_allProjects.push(res.project);
// Now move session into it
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:res.project.project_id})});
session.project_id=res.project.project_id;
await renderSessionList();
showToast('Created "'+res.project.name+'" and moved session');
}
};
picker.appendChild(createItem);
// Append to body and position using getBoundingClientRect so it isn't clipped
// by overflow:hidden on .session-item ancestors
document.body.appendChild(picker);
const rect=anchorEl.getBoundingClientRect();
picker.style.position='fixed';
picker.style.zIndex='999';
// Prefer opening below; flip above if too close to bottom of viewport
const spaceBelow=window.innerHeight-rect.bottom;
if(spaceBelow<160&&rect.top>160){
picker.style.bottom=(window.innerHeight-rect.top+4)+'px';
picker.style.top='auto';
}else{
picker.style.top=(rect.bottom+4)+'px';
picker.style.bottom='auto';
}
// Align right edge of picker with right edge of button; keep within viewport
const pickerW=Math.min(220,Math.max(160,picker.scrollWidth||160));
let left=rect.right-pickerW;
if(left<8) left=8;
picker.style.left=left+'px';
// Close on outside click
const close=(e)=>{if(!picker.contains(e.target)&&e.target!==anchorEl){picker.remove();document.removeEventListener('click',close);}};
setTimeout(()=>document.addEventListener('click',close),0);
}
async function _startProjectCreate(bar, addBtn){
const inp=document.createElement('input');
inp.className='project-create-input';
inp.placeholder='Project name';
const finish=async(save)=>{
if(save&&inp.value.trim()){
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})});
await renderSessionList();
showToast('Project created');
}else{
inp.replaceWith(addBtn);
}
};
inp.onkeydown=(e)=>{
if(e.key==='Enter'){e.preventDefault();finish(true);}
if(e.key==='Escape'){e.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
addBtn.replaceWith(inp);
setTimeout(()=>inp.focus(),10);
}
function _startProjectRename(proj, chip){
const inp=document.createElement('input');
inp.className='project-create-input';
inp.value=proj.name;
const finish=async(save)=>{
if(save&&inp.value.trim()&&inp.value.trim()!==proj.name){
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})});
await renderSessionList();
showToast('Project renamed');
}else{
renderSessionListFromCache();
}
};
inp.onkeydown=(e)=>{
if(e.key==='Enter'){e.preventDefault();finish(true);}
if(e.key==='Escape'){e.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
inp.onclick=(e)=>e.stopPropagation();
chip.replaceWith(inp);
setTimeout(()=>{inp.focus();inp.select();},10);
}
async function _confirmDeleteProject(proj){
const _delProj=await showConfirmDialog({title:`Delete project "${proj.name}"?`,message:'Sessions will be unassigned but not deleted.',confirmLabel:'Delete',danger:true,focusCancel:true});
if(!_delProj) return;
await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})});
if(_activeProject===proj.project_id) _activeProject=null;
await renderSessionList();
showToast('Project deleted');
}