* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255) When base_url is configured in config.yaml, resolve_model_provider() now trusts the configured provider/base_url entirely and skips the slash-based OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom being silently routed to OpenRouter, resulting in 401 errors. Fixes #230 * test: mobile layout regression suite — 14 tests for every QA run (#254) Adds tests/test_mobile_layout.py with 14 static regression tests that run on every QA pass to catch mobile layout breakage before it reaches prod. Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile overlay, bottom nav, files button, profile dropdown z-index, chip overflow, workspace close, 100dvh, 44px touch targets, 16px font-size on textarea. * feat: /skills slash command lists and filters available Hermes skills (#257) Adds /skills [query] command to commands.js. Fetches from /api/skills, groups by category (alphabetically sorted), displays as a formatted assistant message. Optional query filters by name, description, or category. i18n keys added for en, de, zh, zh-Hant. 1 regression test added. Fixes #248 * feat: shared app dialogs replace native confirm()/prompt() calls (#251) Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt call sites across panels.js, sessions.js, ui.js, workspace.js. Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore, ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant. 5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of native dialog calls. Extracted from PR #242. * fix: Android Chrome mobile — workspace panel close + profile dropdown (#256) Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when toggling the right workspace panel. New closeMobileFiles() helper closes the panel with correct overlay state tracking. Overlay onclick calls both closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x) added to workspace panel header. Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at max-width:900px, escaping the overflow-x:auto stacking context that was clipping it on Android Chrome. Fix applied during review: closeMobileSidebar() now checks if the right panel is still open before hiding the overlay, preventing the overlay from disappearing when only the sidebar is closed. Fixes #247 Fixes #246 * feat: session ⋯ action dropdown replaces per-row buttons (#252) Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash) with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full keyboard (Escape), click-outside, scroll, and resize-reposition handling. Position:fixed prevents sidebar clipping. 5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete (danger style). Each with icon and descriptive subtitle. Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons asserts the new trigger and menu functions exist, old per-row classes are gone. Extracted from PR #242. * docs: v0.47.0 release notes, bump version, update test counts (645) --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
709 lines
31 KiB
JavaScript
709 lines
31 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);}
|
|
}
|
|
|
|
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(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');
|
|
}
|
|
|
|
|