feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)

* 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>
This commit is contained in:
nesquena-hermes
2026-04-12 11:55:40 -07:00
committed by GitHub
parent ed2d55f020
commit ede1a5fc50
23 changed files with 1333 additions and 610 deletions

View File

@@ -42,15 +42,15 @@ async function loadCrons() {
<span class="cron-status ${statusClass}">${statusLabel}</span>
</div>
<div class="cron-body" id="cron-body-${job.id}">
<div class="cron-schedule">&#128337; ${esc(job.schedule_display || job.schedule?.expression || '')} &nbsp;|&nbsp; Next: ${esc(nextRun)} &nbsp;|&nbsp; Last: ${esc(lastRun)}</div>
<div class="cron-schedule">${li('clock',12)} ${esc(job.schedule_display || job.schedule?.expression || '')} &nbsp;|&nbsp; Next: ${esc(nextRun)} &nbsp;|&nbsp; Last: ${esc(lastRun)}</div>
<div class="cron-prompt">${esc((job.prompt||'').slice(0,300))}${(job.prompt||'').length>300?'…':''}</div>
<div class="cron-actions">
<button class="cron-btn run" onclick="cronRun('${job.id}')">&#9654; Run now</button>
<button class="cron-btn run" onclick="cronRun('${job.id}')">${li('play',12)} Run now</button>
${statusLabel==='paused'
? `<button class="cron-btn" onclick="cronResume('${job.id}')">&#9654;&#9474; Resume</button>`
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">&#9646;&#9646; Pause</button>`}
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'&quot;')})">&#9998; Edit</button>
<button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">&#128465; Delete</button>
? `<button class="cron-btn" onclick="cronResume('${job.id}')">${li('play',12)} Resume</button>`
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">${li('pause',12)} Pause</button>`}
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'&quot;')})">${li('pencil',12)} Edit</button>
<button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">${li('trash-2',12)} Delete</button>
</div>
<!-- Inline edit form, hidden by default -->
<div id="cron-edit-${job.id}" style="display:none;margin-top:8px;border-top:1px solid var(--border);padding-top:8px">
@@ -172,7 +172,7 @@ async function submitCronCreate(){
if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills;
await api('/api/crons/create',{method:'POST',body:JSON.stringify(body)});
toggleCronForm();
showToast('Job created');
showToast('Job created');
await loadCrons();
}catch(e){
errEl.textContent='Error: '+e.message;errEl.style.display='';
@@ -242,7 +242,7 @@ function toggleCron(id) {
async function cronRun(id) {
try {
await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})});
showToast('Job triggered');
showToast('Job triggered');
setTimeout(() => loadCronOutput(id), 5000);
} catch(e) { showToast('Run failed: ' + e.message, 4000); }
}
@@ -258,7 +258,7 @@ async function cronPause(id) {
async function cronResume(id) {
try {
await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})});
showToast('Job resumed');
showToast('Job resumed');
await loadCrons();
} catch(e) { showToast('Resume failed: ' + e.message, 4000); }
}
@@ -290,7 +290,7 @@ async function cronEditSave(id) {
const updates = {job_id: id, schedule, prompt};
if (name) updates.name = name;
await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)});
showToast('Job updated');
showToast('Job updated');
await loadCrons();
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
}
@@ -326,11 +326,11 @@ function loadTodos() {
panel.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:4px 0">No active task list in this session.</div>';
return;
}
const statusIcon = {pending:'○', in_progress:'◉', completed:'✓', cancelled:'✗'};
const statusIcon = {pending:li('square',14), in_progress:li('loader',14), completed:li('check',14), cancelled:li('x',14)};
const statusColor = {pending:'var(--muted)', in_progress:'var(--blue)', completed:'rgba(100,200,100,.8)', cancelled:'rgba(200,100,100,.5)'};
panel.innerHTML = todos.map(t => `
<div style="display:flex;align-items:flex-start;gap:10px;padding:6px 0;border-bottom:1px solid var(--border);">
<span style="font-size:14px;flex-shrink:0;margin-top:1px;color:${statusColor[t.status]||'var(--muted)'}">${statusIcon[t.status]||'○'}</span>
<span style="font-size:14px;display:inline-flex;align-items:center;flex-shrink:0;margin-top:1px;color:${statusColor[t.status]||'var(--muted)'}">${statusIcon[t.status]||li('square',14)}</span>
<div style="flex:1;min-width:0">
<div style="font-size:13px;color:${t.status==='completed'?'var(--muted)':t.status==='in_progress'?'var(--text)':'var(--text)'};${t.status==='completed'?'text-decoration:line-through;opacity:.5':''};line-height:1.4">${esc(t.content)}</div>
<div style="font-size:10px;color:var(--muted);margin-top:2px;opacity:.6">${esc(t.id)} · ${esc(t.status)}</div>
@@ -385,7 +385,7 @@ function renderSkills(skills) {
for (const [cat, items] of Object.entries(cats).sort()) {
const sec = document.createElement('div');
sec.className = 'skills-category';
sec.innerHTML = `<div class="skills-cat-header">&#128193; ${esc(cat)} <span style="opacity:.5">(${items.length})</span></div>`;
sec.innerHTML = `<div class="skills-cat-header">${li('folder',12)} ${esc(cat)} <span style="opacity:.5">(${items.length})</span></div>`;
for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) {
const el = document.createElement('div');
el.className = 'skill-item';
@@ -482,7 +482,7 @@ async function submitSkillSave() {
if (!content.trim()) { errEl.textContent = 'Content is required'; errEl.style.display = ''; return; }
try {
await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})});
showToast(_editingSkillName ? 'Skill updated' : 'Skill created');
showToast(_editingSkillName ? 'Skill updated' : 'Skill created');
_skillsData = null;
toggleSkillForm();
await loadSkills();
@@ -514,7 +514,7 @@ async function submitMemorySave() {
errEl.style.display = 'none';
try {
await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: 'memory', content})});
showToast('Memory saved');
showToast('Memory saved');
closeMemoryEdit();
await loadMemory(true);
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
@@ -532,48 +532,95 @@ function getWorkspaceFriendlyName(path){
return path.split('/').filter(Boolean).pop()||path;
}
function syncWorkspaceDisplays(){
const hasSession=!!(S.session&&S.session.workspace);
const ws=hasSession?S.session.workspace:'';
const label=hasSession?getWorkspaceFriendlyName(ws):t('no_workspace');
const sidebarName=$('sidebarWsName');
const sidebarPath=$('sidebarWsPath');
if(sidebarName) sidebarName.textContent=label;
if(sidebarPath) sidebarPath.textContent=ws;
const composerChip=$('composerWorkspaceChip');
const composerLabel=$('composerWorkspaceLabel');
const composerDropdown=$('composerWsDropdown');
if(!hasSession && composerDropdown) composerDropdown.classList.remove('open');
if(composerLabel) composerLabel.textContent=label;
if(composerChip){
composerChip.disabled=!hasSession;
composerChip.title=hasSession?ws:'No active workspace';
composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open')));
}
}
async function loadWorkspaceList(){
try{
const data = await api('/api/workspaces');
_workspaceList = data.workspaces || [];
// Refresh sidebar display if we have a current session
if(S.session && S.session.workspace) {
const sidebarName=$('sidebarWsName');
const sidebarPath=$('sidebarWsPath');
if(sidebarName) sidebarName.textContent=getWorkspaceFriendlyName(S.session.workspace);
if(sidebarPath) sidebarPath.textContent=S.session.workspace;
}
syncWorkspaceDisplays();
return data;
}catch(e){ return {workspaces:[], last:''}; }
}
function renderWorkspaceDropdown(workspaces, currentWs){
const dd = $('wsDropdown');
function _renderWorkspaceAction(label, meta, iconSvg, onClick){
const opt=document.createElement('div');
opt.className='ws-opt ws-opt-action';
opt.innerHTML=`<span class="ws-opt-icon">${iconSvg}</span><span><span class="ws-opt-name">${esc(label)}</span>${meta?`<span class="ws-opt-meta">${esc(meta)}</span>`:''}</span>`;
opt.onclick=onClick;
return opt;
}
function _positionComposerWsDropdown(){
const dd=$('composerWsDropdown');
const chip=$('composerWorkspaceChip');
const footer=document.querySelector('.composer-footer');
if(!dd||!chip||!footer)return;
const chipRect=chip.getBoundingClientRect();
const footerRect=footer.getBoundingClientRect();
let left=chipRect.left-footerRect.left;
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
left=Math.max(0, Math.min(left, maxLeft));
dd.style.left=`${left}px`;
}
function _positionProfileDropdown(){
const dd=$('profileDropdown');
const chip=$('profileChip');
const footer=document.querySelector('.composer-footer');
if(!dd||!chip||!footer)return;
const chipRect=chip.getBoundingClientRect();
const footerRect=footer.getBoundingClientRect();
let left=chipRect.left-footerRect.left;
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
left=Math.max(0, Math.min(left, maxLeft));
dd.style.left=`${left}px`;
}
function renderWorkspaceDropdownInto(dd, workspaces, currentWs){
if(!dd)return;
dd.innerHTML='';
for(const w of workspaces){
const opt=document.createElement('div');
opt.className='ws-opt'+(w.path===currentWs?' active':'');
opt.innerHTML=`<span class="ws-opt-name">${esc(w.name)}</span><span class="ws-opt-path">${esc(w.path)}</span>`;
opt.onclick=async()=>{
closeWsDropdown();
if(!S.session||w.path===S.session.workspace)return;
await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id, workspace:w.path, model:S.session.model
})});
S.session.workspace=w.path;
syncTopbar();
await loadDir('.');
showToast(`Switched to ${w.name}`);
};
opt.onclick=()=>switchToWorkspace(w.path,w.name);
dd.appendChild(opt);
}
// Divider + Manage link
dd.appendChild(document.createElement('div')).className='ws-divider';
dd.appendChild(_renderWorkspaceAction(
'Choose workspace path',
'Add a validated path and switch this conversation',
li('folder',12),
()=>promptWorkspacePath()
));
const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div);
const mgmt=document.createElement('div');mgmt.className='ws-opt ws-manage';
mgmt.innerHTML='&#9881; Manage workspaces';
mgmt.onclick=()=>{closeWsDropdown();switchPanel('workspaces');};
dd.appendChild(mgmt);
dd.appendChild(_renderWorkspaceAction(
'Manage workspaces',
'Open the Spaces panel',
li('settings',12),
()=>{closeWsDropdown();mobileSwitchPanel('workspaces');}
));
}
function toggleWsDropdown(){
@@ -584,18 +631,47 @@ function toggleWsDropdown(){
else{
closeProfileDropdown(); // close profile dropdown if open
loadWorkspaceList().then(data=>{
renderWorkspaceDropdown(data.workspaces, S.session?S.session.workspace:'');
renderWorkspaceDropdownInto(dd, data.workspaces, S.session?S.session.workspace:'');
dd.classList.add('open');
});
}
}
function toggleComposerWsDropdown(){
const dd=$('composerWsDropdown');
const chip=$('composerWorkspaceChip');
if(!dd||!chip||chip.disabled)return;
const open=dd.classList.contains('open');
if(open){closeWsDropdown();}
else{
closeProfileDropdown();
if(typeof closeModelDropdown==='function') closeModelDropdown();
loadWorkspaceList().then(data=>{
renderWorkspaceDropdownInto(dd, data.workspaces, S.session?S.session.workspace:'');
dd.classList.add('open');
_positionComposerWsDropdown();
chip.classList.add('active');
});
}
}
function closeWsDropdown(){
const dd=$('wsDropdown');
const composerDd=$('composerWsDropdown');
const composerChip=$('composerWorkspaceChip');
if(dd)dd.classList.remove('open');
if(composerDd)composerDd.classList.remove('open');
if(composerChip)composerChip.classList.remove('active');
}
document.addEventListener('click',e=>{
if(!e.target.closest('#sidebarWsDisplay') && !e.target.closest('#wsDropdown'))closeWsDropdown();
if(
!e.target.closest('#composerWorkspaceChip') &&
!e.target.closest('#composerWsDropdown')
) closeWsDropdown();
});
window.addEventListener('resize',()=>{
const dd=$('composerWsDropdown');
if(dd&&dd.classList.contains('open')) _positionComposerWsDropdown();
});
async function loadWorkspacesPanel(){
@@ -616,15 +692,15 @@ function renderWorkspacesPanel(workspaces){
<div class="ws-row-path">${esc(w.path)}</div>
</div>
<div class="ws-row-actions">
<button class="ws-action-btn" title="Use in current session" onclick="switchToWorkspace('${esc(w.path)}','${esc(w.name)}')">&#8594; Use</button>
<button class="ws-action-btn danger" title="Remove" onclick="removeWorkspace('${esc(w.path)}')">&#10005;</button>
<button class="ws-action-btn" title="Use in current session" onclick="switchToWorkspace('${esc(w.path)}','${esc(w.name)}')">${li('arrow-right',12)} Use</button>
<button class="ws-action-btn danger" title="Remove" onclick="removeWorkspace('${esc(w.path)}')">${li('x',12)}</button>
</div>`;
panel.appendChild(row);
}
const addRow=document.createElement('div');addRow.className='ws-add-row';
addRow.innerHTML=`
<input id="wsAddInput" placeholder="Add workspace path (e.g. /home/user/my-project)" style="flex:1;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;">
<button class="ws-action-btn" onclick="addWorkspace()">&#43; Add</button>`;
<button class="ws-action-btn" onclick="addWorkspace()">${li('plus',12)} Add</button>`;
panel.appendChild(addRow);
const hint=document.createElement('div');
hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px';
@@ -656,16 +732,58 @@ async function removeWorkspace(path){
}catch(e){setStatus('Remove failed: '+e.message);}
}
async function promptWorkspacePath(){
if(!S.session)return;
const value=await showPromptDialog({
title:'Switch workspace',
message:'Enter an absolute workspace path to add and switch this conversation to.',
confirmLabel:'Switch',
placeholder:'/Users/you/project',
value:S.session.workspace||''
});
const path=(value||'').trim();
if(!path)return;
try{
const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})});
_workspaceList=data.workspaces||[];
const target=_workspaceList[_workspaceList.length-1];
if(!target) throw new Error('Workspace was not added');
await switchToWorkspace(target.path,target.name);
}catch(e){
if(String(e.message||'').includes('Workspace already in list')){
showToast('Workspace already saved — choose it from the list');
return;
}
showToast('Workspace switch failed: '+e.message);
}
}
async function switchToWorkspace(path,name){
if(!S.session)return;
if(S.busy){
showToast('Cannot switch workspace while agent is running');
return;
}
if(typeof _previewDirty!=='undefined'&&_previewDirty){
const discard=await showConfirmDialog({
title:'Discard file edits?',
message:'Switching workspaces will discard unsaved file edits in the preview.',
confirmLabel:t('discard'),
danger:true
});
if(!discard)return;
if(typeof cancelEditMode==='function')cancelEditMode();
if(typeof clearPreview==='function')clearPreview();
}
try{
closeWsDropdown();
await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id, workspace:path, model:S.session.model
})});
S.session.workspace=path;
syncTopbar();
await loadDir('.');
showToast(`Switched to ${name}`);
showToast(`Switched to ${name||getWorkspaceFriendlyName(path)}`);
}catch(e){setStatus('Switch failed: '+e.message);}
}
@@ -704,7 +822,7 @@ async function loadProfilesPanel() {
</div>
<div class="profile-card-actions">
${!isActive ? `<button class="ws-action-btn" onclick="switchToProfile('${esc(p.name)}')" title="Switch to this profile">Use</button>` : ''}
${!p.is_default ? `<button class="ws-action-btn danger" onclick="deleteProfile('${esc(p.name)}')" title="Delete this profile">&#10005;</button>` : ''}
${!p.is_default ? `<button class="ws-action-btn danger" onclick="deleteProfile('${esc(p.name)}')" title="Delete this profile">${li('x',12)}</button>` : ''}
</div>
</div>`;
panel.appendChild(card);
@@ -740,8 +858,8 @@ function renderProfileDropdown(data) {
// Divider + Manage link
const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div);
const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage';
mgmt.innerHTML = '&#9881; Manage profiles';
mgmt.onclick = () => { closeProfileDropdown(); switchPanel('profiles'); };
mgmt.innerHTML = `${li('settings',12)} Manage profiles`;
mgmt.onclick = () => { closeProfileDropdown(); mobileSwitchPanel('profiles'); };
dd.appendChild(mgmt);
}
@@ -750,18 +868,28 @@ function toggleProfileDropdown() {
if (!dd) return;
if (dd.classList.contains('open')) { closeProfileDropdown(); return; }
closeWsDropdown(); // close workspace dropdown if open
if(typeof closeModelDropdown==='function') closeModelDropdown();
api('/api/profiles').then(data => {
renderProfileDropdown(data);
dd.classList.add('open');
_positionProfileDropdown();
const chip=$('profileChip');
if(chip) chip.classList.add('active');
}).catch(e => { showToast('Failed to load profiles'); });
}
function closeProfileDropdown() {
const dd = $('profileDropdown');
if (dd) dd.classList.remove('open');
const chip=$('profileChip');
if(chip) chip.classList.remove('active');
}
document.addEventListener('click', e => {
if (!e.target.closest('#profileChipWrap')) closeProfileDropdown();
if (!e.target.closest('#profileChipWrap') && !e.target.closest('#profileDropdown')) closeProfileDropdown();
});
window.addEventListener('resize',()=>{
const dd=$('profileDropdown');
if(dd&&dd.classList.contains('open')) _positionProfileDropdown();
});
async function switchToProfile(name) {
@@ -894,7 +1022,7 @@ async function loadMemory(force) {
panel.innerHTML = `
<div class="memory-section">
<div class="memory-section-title">
&#129504; My Notes
<span style="display:inline-flex;align-items:center;gap:6px">${li('brain',14)} My Notes</span>
<span class="memory-mtime">${fmtTime(data.memory_mtime)}</span>
</div>
${data.memory
@@ -903,7 +1031,7 @@ async function loadMemory(force) {
</div>
<div class="memory-section">
<div class="memory-section-title">
&#128100; User Profile
<span style="display:inline-flex;align-items:center;gap:6px">${li('user',14)} User Profile</span>
<span class="memory-mtime">${fmtTime(data.user_mtime)}</span>
</div>
${data.user
@@ -924,6 +1052,44 @@ document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.class
let _settingsDirty = false;
let _settingsThemeOnOpen = null; // track theme at open time for discard revert
let _settingsSection = 'conversation';
function switchSettingsSection(name){
const section=(name==='preferences'||name==='system')?name:'conversation';
_settingsSection=section;
const map={conversation:'Conversation',preferences:'Preferences',system:'System'};
['conversation','preferences','system'].forEach(key=>{
const tab=$('settingsTab'+map[key]);
const pane=$('settingsPane'+map[key]);
const active=key===section;
if(tab){
tab.classList.toggle('active',active);
tab.setAttribute('aria-selected',active?'true':'false');
}
if(pane) pane.classList.toggle('active',active);
});
}
function _syncHermesPanelSessionActions(){
const hasSession=!!S.session;
const visibleMessages=hasSession?(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length:0;
const title=hasSession?(S.session.title||'Untitled'):'No active conversation selected.';
const meta=$('hermesSessionMeta');
if(meta){
meta.textContent=hasSession
? `${title} · ${visibleMessages} message${visibleMessages===1?'':'s'}`
: 'No active conversation selected.';
}
const setDisabled=(id,disabled)=>{
const el=$(id);
if(!el)return;
el.disabled=!!disabled;
el.classList.toggle('disabled',!!disabled);
};
setDisabled('btnDownload',!hasSession||visibleMessages===0);
setDisabled('btnExportJSON',!hasSession);
setDisabled('btnClearConvModal',!hasSession||visibleMessages===0);
}
function toggleSettings(){
const overlay=$('settingsOverlay');
@@ -931,6 +1097,7 @@ function toggleSettings(){
if(overlay.style.display==='none'){
_settingsDirty = false;
_settingsThemeOnOpen = document.documentElement.dataset.theme || 'dark';
_settingsSection = 'conversation';
overlay.style.display='';
loadSettingsPanel();
} else {
@@ -938,12 +1105,26 @@ function toggleSettings(){
}
}
function _resetSettingsPanelState(){
_settingsSection = 'conversation';
switchSettingsSection('conversation');
const bar=$('settingsUnsavedBar');
if(bar) bar.style.display='none';
}
function _hideSettingsPanel(){
const overlay=$('settingsOverlay');
if(!overlay) return;
_resetSettingsPanelState();
overlay.style.display='none';
}
// Close with unsaved-changes check. If dirty, show a confirm dialog.
function _closeSettingsPanel(){
if(!_settingsDirty){
// Nothing changed -- revert any live preview and close
_revertSettingsPreview();
$('settingsOverlay').style.display='none';
_hideSettingsPanel();
return;
}
// Dirty -- show inline confirm bar
@@ -971,14 +1152,14 @@ function _showSettingsUnsavedBar(){
+ '<button onclick="_discardSettings()" style="padding:5px 12px;border-radius:6px;border:1px solid var(--border2);background:rgba(255,255,255,.06);color:var(--muted);cursor:pointer;font-size:12px;font-weight:600">Discard</button>'
+ '<button onclick="saveSettings(true)" style="padding:5px 12px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">Save</button>'
+ '</span>';
const body = document.querySelector('.settings-body') || document.querySelector('.settings-panel');
const body = document.querySelector('.settings-main') || document.querySelector('.settings-body') || document.querySelector('.settings-panel');
if(body) body.prepend(bar);
}
function _discardSettings(){
_revertSettingsPreview();
_settingsDirty = false;
$('settingsOverlay').style.display = 'none';
_hideSettingsPanel();
}
// Mark settings as dirty whenever anything changes
@@ -1058,6 +1239,8 @@ async function loadSettingsPanel(){
const disableBtn=$('btnDisableAuth');
if(disableBtn) disableBtn.style.display=active?'':'none';
}catch(e){}
_syncHermesPanelSessionActions();
switchSettingsSection(_settingsSection);
}catch(e){
showToast(t('settings_load_failed')+e.message);
}
@@ -1097,8 +1280,7 @@ async function saveSettings(andClose){
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
showToast(t('settings_saved_pw'));
_settingsDirty=false; _settingsThemeOnOpen=theme;
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
$('settingsOverlay').style.display='none';
_hideSettingsPanel();
return;
}catch(e){showToast('Save failed: '+e.message);return;}
}
@@ -1121,7 +1303,7 @@ async function saveSettings(andClose){
if(typeof syncTopbar==='function') syncTopbar();
if(typeof renderSessionList==='function') renderSessionList();
showToast(t('settings_saved'));
$('settingsOverlay').style.display='none';
_hideSettingsPanel();
}catch(e){
showToast(t('settings_save_failed')+e.message);
}
@@ -1172,8 +1354,7 @@ function startCronPolling(){
const data=await api(`/api/crons/recent?since=${_cronPollSince}`);
if(data.completions&&data.completions.length>0){
for(const c of data.completions){
const icon=c.status==='error'?'\u274c':'\u2705';
showToast(`${icon} Cron "${c.name}" ${c.status==='error'?'failed':'completed'}`,4000);
showToast(`Cron "${c.name}" ${c.status==='error'?'failed':'completed'}`,4000);
_cronPollSince=Math.max(_cronPollSince,c.completed_at);
}
_cronUnreadCount+=data.completions.length;