* Polish workspace panel behavior and app dialogs * Replace remaining emoji UI glyphs with Lucide icons * Redesign composer footer around model and context controls Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill. Design credit and inspiration: Theo / T3 Code. Reference implementation: https://github.com/pingdotgg/t3code/ * Remove obsolete activity bar Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts. This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status. * Move workspace and model switching into composer footer * Move profile switching into composer footer * Refactor Hermes control center UI * Redesign control center settings modal layout Widen the modal to 860px, simplify the tab list to icon+label rows, stretch the tab column's divider to full height, lock the panel to a fixed height so switching tabs no longer resizes the outer shell, and always open on the Conversation tab. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Put session item actions in a dropdown * Use Hermes mark in sidebar control button * Reset control center section on close * Drop session-item left border indicator Remove the left-border accent used for active, CLI, and project rows — each state already has a dedicated cue (gold fill, cli badge, project dot), so the border was redundant. Fully round the row, add 2px bottom spacing between rows, and strip the matching JS/CSS overrides. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Increase session search input vertical padding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Normalise odd pixel values across UI Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid across composer chips, sidebar panels, cron list, settings, approval buttons, dropdowns, and inline message edit — eliminating the 7/9/11px drift that was making sibling elements feel subtly misaligned. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite) The mobile layout regression suite (test_mobile_layout.py) requires: - #btnMobileFiles onclick=toggleMobileFiles() in topbar chips - .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports. * Improve composer footer mobile responsiveness and UX - Collapse composer chips to icon-only at <=400px viewports - Add model chip icon (CPU) so it remains tappable when labels are hidden - Show send button always (disabled state when empty, hidden during streaming) - Show context usage indicator on session load, not just after streaming - Add cancel status fallback timeout to prevent stale "Cancelling..." text - Update tests to match new send button and busy state behavior * Fix duplicate files button and broken workspace close on mobile Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle in the mobile topbar. Fix workspace panel close button calling undefined closeMobileFiles() — now calls closeWorkspacePanel(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix model chip icon vertical alignment in composer footer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix workspace toggle button hidden on desktop by conflicting CSS class Remove mobile-files-btn class from #btnWorkspacePanelToggle — its display:none!important rule was overriding workspace-toggle-btn visibility on non-mobile viewports. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix session actions dots button inaccessible on mobile sidebar Always show the session actions trigger on mobile (no hover state on touch devices) and restore right padding so text truncates with ellipsis before the dots icon. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix composer footer manage links not opening sidebar panel The "Manage profiles" and "Manage workspaces" links in the composer footer dropdowns called switchPanel() which only changes the active panel content but doesn't open the sidebar. Replaced with mobileSwitchPanel() which also opens the sidebar so the panel is actually visible. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Widen icon-only composer chips breakpoint from 400px to 768px Move the icon-only chip styling up into the existing max-width:768px media query so chips collapse to icon-only on tablets too, preventing composer footer overflow on mid-size screens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix composer-left vertical scrollbar by setting overflow-y:hidden When overflow-x is set to auto, the CSS spec implicitly changes overflow-y from visible to auto, allowing a vertical scrollbar to appear from slight chip padding/border overflow. Explicitly set overflow-y:hidden to prevent this. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve rebase conflicts and fix control center test assertions - Resolved 4 conflicts during rebase onto master (workspace.js, boot.js, index.html, test_sprint34.py) - Fixed test_sprint34.py: _controlSection -> _settingsSection, cc-tab -> settings-tabs (matching actual implementation) - Fixed quoting syntax error in test assertion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update version badge in System tab to v0.49.4 * docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge --------- Co-authored-by: Aron Prins <pwf.aron@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
753 lines
32 KiB
JavaScript
753 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>',
|
|
};
|
|
|
|
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);setComposerStatus('');
|
|
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;
|
|
updateSendBtn();
|
|
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
|
setStatus('');
|
|
setComposerStatus('');
|
|
clearLiveToolCards();
|
|
syncTopbar();await loadDir('.');renderMessages();highlightCode();
|
|
}
|
|
// Sync context usage indicator from session data
|
|
const _s=S.session;
|
|
if(_s&&typeof _syncCtxIndicator==='function'){
|
|
const u=S.lastUsage||{};
|
|
_syncCtxIndicator({input_tokens:_s.input_tokens||u.input_tokens||0,output_tokens:_s.output_tokens||u.output_tokens||0,estimated_cost:_s.estimated_cost||u.estimated_cost,context_length:u.context_length||0,last_prompt_tokens:u.last_prompt_tokens||0,threshold_tokens:u.threshold_tokens||0});
|
|
}
|
|
}
|
|
|
|
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
|
|
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 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;
|
|
closeSessionActionMenu();
|
|
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=()=>{
|
|
closeSessionActionMenu();
|
|
_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 dot appended after the title
|
|
if(s.project_id){
|
|
const proj=_allProjects.find(p=>p.project_id===s.project_id);
|
|
if(proj){
|
|
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);
|
|
// Single trigger button that opens a shared dropdown menu
|
|
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 ok=await showConfirmDialog({
|
|
message:'Delete this conversation?',
|
|
confirmLabel:t('delete_title'),
|
|
danger:true
|
|
});
|
|
if(!ok)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);
|
|
const name=await showPromptDialog({
|
|
message:t('project_name_prompt'),
|
|
confirmLabel:t('create'),
|
|
placeholder:'Project name'
|
|
});
|
|
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);
|
|
}
|
|
|
|
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 ok=await showConfirmDialog({
|
|
message:'Delete project "'+proj.name+'"? Sessions will be unassigned but not deleted.',
|
|
confirmLabel:t('delete_title'),
|
|
danger:true
|
|
});
|
|
if(!ok){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');
|
|
}
|