Merge pull request #2054 from nesquena/feat/sidebar-collapse-fused

feat(ux): collapse sidebar by clicking the active rail icon (fuses #1884 + #1924)
This commit is contained in:
nesquena-hermes
2026-05-10 22:04:29 -07:00
committed by GitHub
8 changed files with 568 additions and 31 deletions
+24
View File
@@ -2,6 +2,30 @@
## [Unreleased]
## [v0.51.43] — 2026-05-11 — Release S (fused community PR — desktop sidebar collapse)
### Added
- **PR #2054** by @jasonjcwu and @spektro33 (fused, co-authored) — Desktop users can now collapse the session-list sidebar by clicking the already-active rail icon, or with Cmd/Ctrl+B. State persists across reloads via localStorage and survives bfcache restores. Two discoverability paths, **no new visible UI affordance** — default appearance is identical to master, only users who actively try to toggle ever see a difference. Cross-panel rail clicks behave exactly as before (no collapse, just panel switch). Mobile (<641px) is unaffected. The behaviour is gated behind one new `opts.fromRailClick` flag on `switchPanel()` so every programmatic call-site (commands, deeplinks, internal state changes) preserves master semantics exactly. Inline `<script>` flash-prevention in `<head>` sets `data-sidebar-collapsed='1'` on `<html>` BEFORE the stylesheet loads, so cold loads with persisted-collapsed state paint correctly from frame 0 with no flicker. `aria-expanded` mirrors open/collapsed state on the active rail button for screen-reader announcements. Smooth `.24s cubic-bezier(.22,1,.36,1)` slide animation matches the workspace-panel collapse on the right. Drag-resize cursor stays instant via `body.resizing .sidebar { transition:none }`. Closes #1884 (jasonjcwu) and #1924 (spektro33).
### Fixed (maintainer review on PR #2054)
- **CSS breakpoint asymmetry** — pre-fix, the JS `_isDesktopWidth()` guard matched `min-width:641px` (where the rail itself becomes visible) but the `.sidebar-collapsed` CSS rules were inside `@media(min-width:901px)` (copied from the workspace-panel block without thinking). In the 641-900px band (tablet portrait, small laptop windows), clicking the active rail icon would write `.sidebar-collapsed` to the DOM, set `aria-expanded='false'`, and persist `localStorage='1'` — but the sidebar would visually stay open at 300px because CSS didn't match. User sees no visual change, screen reader announces "collapsed" for a still-visible sidebar, then resizing ≥901px later collapses by surprise. Fix hoists the three `.sidebar-collapsed` rules into their own `@media(min-width:641px)` block. Caught by @nesquena reviewing PR #2054; new regression test `test_css_breakpoint_matches_js_isdesktopwidth` parses both files at every CI run and asserts the JS / CSS thresholds match.
### Test infrastructure
- **`AWS_EC2_METADATA_DISABLED=true` set at conftest module load** — botocore's credential chain probes EC2 IMDS (169.254.169.254) by default during agent imports. On VPS hosts where IMDS is reachable but rate-limited (HTTP 429), this dragged a 161s test run to 600+s. Matches the guard `hermes_cli/doctor.py` already uses in its parallel-probe block.
- **Credential-strip allowlist expanded from 6 prefixes to 40+** — the test_server fixture now strips MEM0, XAI, MISTRAL, OLLAMA, GROQ, AWS, Azure OpenAI, messaging bot tokens, search-engine API keys, image-gen keys, GitHub tokens, etc. before spawning the test server. Defence-in-depth against accidental outbound API calls from tests; a real outbound TLS connection to a provider's IPv6 endpoint was observed during test runs before the expansion. The test server uses a mock config and should never make real provider calls.
### Tests
5,120 → **5,166 collected** (+46 net new across the 35-test structural suite for sidebar collapse, the CSS-breakpoint regression guard nesquena added, and small per-locale i18n additions in dependent suites). All passing on Python 3.11/3.12/3.13.
### Notes
- This is the first PR in the repo where the maintainer review caught a real defect (CSS breakpoint asymmetry) before merge AND the fix was pushed directly onto the contributor's branch with a regression test. The merged commit includes both the original fusion and the fix as separate authored commits, preserving the audit trail.
## [v0.51.42] — 2026-05-11 — Release R (5-PR contributor batch — session recovery state.db reconciliation + RFC convention + MEDIA_ALLOWED_ROOTS + Slack cron delivery)
### Added
+78
View File
@@ -223,6 +223,62 @@ function closeMobileSidebar(){
if(sidebar)sidebar.classList.remove('mobile-open');
if(overlay)overlay.classList.remove('visible');
}
// ── Desktop sidebar collapse toggle ────────────────────────────────────────
// Two discoverability paths into the same state:
// (1) Click the already-active rail icon → collapse / expand the sidebar.
// (2) Cmd/Ctrl+B keyboard shortcut (VS Code convention).
// Mobile is unaffected: the sidebar is an overlay there, and every collapse
// code path is gated on `_isDesktopWidth()` (min-width:641px).
// State is persisted via localStorage and survives reloads + bfcache.
const _SIDEBAR_COLLAPSED_KEY='hermes-webui-sidebar-collapsed';
function _isDesktopWidth(){
try{return window.matchMedia('(min-width:641px)').matches;}catch(_){return true;}
}
function _isSidebarCollapsed(){
return document.querySelector('.layout')?.classList.contains('sidebar-collapsed')||false;
}
function _syncSidebarAria(){
// Mirror the open/collapsed state on the active rail button via aria-expanded
// so screen readers announce the toggle. Open=true, collapsed=false.
const active=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]');
if(active)active.setAttribute('aria-expanded',!_isSidebarCollapsed());
}
function toggleSidebar(forceState){
if(!_isDesktopWidth())return; // mobile uses an overlay; never collapse there
const layout=document.querySelector('.layout');
if(!layout)return;
const next=typeof forceState==='boolean'?forceState:!_isSidebarCollapsed();
layout.classList.toggle('sidebar-collapsed',next);
// Clear the flash-prevention root-level marker once JS owns the state.
try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){}
try{localStorage.setItem(_SIDEBAR_COLLAPSED_KEY,next?'1':'0');}catch(_){}
_syncSidebarAria();
}
function expandSidebar(){
if(_isSidebarCollapsed())toggleSidebar(false);
}
// Boot-time restore. The inline flash-prevention script in index.html already
// set data-sidebar-collapsed='1' on <html> before the stylesheet so the page
// renders collapsed without paint flash. This IIFE promotes that pre-paint
// state into the .layout class system where both JS and CSS can read it.
(function _restoreSidebarState(){
try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){}
if(!_isDesktopWidth())return;
try{
if(localStorage.getItem(_SIDEBAR_COLLAPSED_KEY)==='1'){
const layout=document.querySelector('.layout');
if(layout)layout.classList.add('sidebar-collapsed');
}
}catch(_){}
_syncSidebarAria();
})();
function toggleMobileFiles(){
toggleWorkspacePanel();
}
@@ -948,6 +1004,18 @@ $('msg').addEventListener('keydown',e=>{
});
// B14: Cmd/Ctrl+K creates a new chat from anywhere
document.addEventListener('keydown',async e=>{
// Cmd/Ctrl+B toggles desktop sidebar collapse (VS Code convention).
// Skip when typing in an input/textarea/contenteditable so text-edit
// shortcuts (e.g. bold in some embedded editors) are never stolen.
if((e.metaKey||e.ctrlKey)&&!e.shiftKey&&!e.altKey&&(e.key==='b'||e.key==='B')){
const t=e.target;
const isText=t&&(t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable);
if(!isText&&typeof toggleSidebar==='function'&&_isDesktopWidth()){
e.preventDefault();
toggleSidebar();
return;
}
}
// Enter on approval card = Allow once (when a button inside the card is focused or
// card is visible and focus is not on an input/textarea/select)
if(e.key==='Enter'&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey){
@@ -1526,4 +1594,14 @@ window.addEventListener('pageshow', async (event) => {
}
// Restart the gateway SSE watcher — the persisted connection is dead after bfcache
if (typeof startGatewaySSE === 'function') try { startGatewaySSE(); } catch (_) {}
// Re-sync sidebar collapse state from localStorage. bfcache restored the
// frozen DOM but another tab may have toggled the sidebar in the meantime.
if (typeof _isSidebarCollapsed === 'function' && typeof toggleSidebar === 'function') {
try {
const _want = localStorage.getItem('hermes-webui-sidebar-collapsed') === '1';
const _have = _isSidebarCollapsed();
if (_want !== _have) toggleSidebar(_want);
if (typeof _syncSidebarAria === 'function') _syncSidebarAria();
} catch (_) {}
}
});
+23 -22
View File
@@ -23,6 +23,7 @@
<meta name="theme-color" id="hermes-theme-color" content="#0D0D1A">
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#0D0D1A':'#FEFCF7';document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute('content',c);m.removeAttribute('media');});}catch(e){}})()</script>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
<script>(function(){try{if(localStorage.getItem('hermes-webui-sidebar-collapsed')==='1')document.documentElement.dataset.sidebarCollapsed='1';}catch(e){}})()</script>
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" integrity="sha384-LJcOxlx9IMbNXDqJ2axpfEQKkAYbFjJfhXexLfiRJhjDU81mzgkiQq8rkV0j6dVh" crossorigin="anonymous">
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
@@ -86,36 +87,36 @@
</header>
<div class="layout">
<nav class="rail" aria-label="Primary navigation">
<button class="rail-btn nav-tab active has-tooltip" data-panel="chat" onclick="switchPanel('chat')" data-tooltip="Chat" data-i18n-title="tab_chat" aria-label="Chat"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="tasks" onclick="switchPanel('tasks')" data-tooltip="Tasks" data-i18n-title="tab_tasks" aria-label="Tasks"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="kanban" onclick="switchPanel('kanban')" data-tooltip="Kanban" data-i18n-title="tab_kanban" aria-label="Kanban"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16"/><path d="M16 4v16"/><path d="M3 10h18"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="skills" onclick="switchPanel('skills')" data-tooltip="Skills" data-i18n-title="tab_skills" aria-label="Skills"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="memory" onclick="switchPanel('memory')" data-tooltip="Memory" data-i18n-title="tab_memory" aria-label="Memory"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="workspaces" onclick="switchPanel('workspaces')" data-tooltip="Spaces" data-i18n-title="tab_workspaces" aria-label="Spaces"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="profiles" onclick="switchPanel('profiles')" data-tooltip="Agent profiles" data-i18n-title="tab_profiles" aria-label="Agent profiles"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="todos" onclick="switchPanel('todos')" data-tooltip="Current task list" data-i18n-title="tab_todos" aria-label="Todos"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="insights" onclick="switchPanel('insights')" data-tooltip="Insights" data-i18n-title="tab_insights" aria-label="Insights"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
<button class="rail-btn nav-tab active has-tooltip" data-panel="chat" onclick="switchPanel('chat',{fromRailClick:true})" data-tooltip="Chat" data-i18n-title="tab_chat" aria-label="Chat"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="tasks" onclick="switchPanel('tasks',{fromRailClick:true})" data-tooltip="Tasks" data-i18n-title="tab_tasks" aria-label="Tasks"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="kanban" onclick="switchPanel('kanban',{fromRailClick:true})" data-tooltip="Kanban" data-i18n-title="tab_kanban" aria-label="Kanban"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16"/><path d="M16 4v16"/><path d="M3 10h18"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="skills" onclick="switchPanel('skills',{fromRailClick:true})" data-tooltip="Skills" data-i18n-title="tab_skills" aria-label="Skills"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="memory" onclick="switchPanel('memory',{fromRailClick:true})" data-tooltip="Memory" data-i18n-title="tab_memory" aria-label="Memory"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="workspaces" onclick="switchPanel('workspaces',{fromRailClick:true})" data-tooltip="Spaces" data-i18n-title="tab_workspaces" aria-label="Spaces"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="profiles" onclick="switchPanel('profiles',{fromRailClick:true})" data-tooltip="Agent profiles" data-i18n-title="tab_profiles" aria-label="Agent profiles"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="todos" onclick="switchPanel('todos',{fromRailClick:true})" data-tooltip="Current task list" data-i18n-title="tab_todos" aria-label="Todos"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="insights" onclick="switchPanel('insights',{fromRailClick:true})" data-tooltip="Insights" data-i18n-title="tab_insights" aria-label="Insights"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
<button class="rail-btn nav-tab dashboard-link has-tooltip" id="dashboardRailBtn" data-dashboard-link style="display:none" onclick="openHermesDashboard(event)" data-tooltip="Hermes Dashboard" data-i18n-title="tab_dashboard" aria-label="Hermes Dashboard"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg><span class="dashboard-external-badge" aria-hidden="true"></span></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="logs" onclick="switchPanel('logs')" data-tooltip="Logs" data-i18n-title="tab_logs" aria-label="Logs"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="logs" onclick="switchPanel('logs',{fromRailClick:true})" data-tooltip="Logs" data-i18n-title="tab_logs" aria-label="Logs"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/></svg></button>
<div class="rail-spacer"></div>
<button class="rail-btn nav-tab has-tooltip" data-panel="settings" onclick="switchPanel('settings')" data-tooltip="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<button class="rail-btn nav-tab has-tooltip" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</nav>
<aside class="sidebar">
<div class="sidebar-nav">
<button class="nav-tab active has-tooltip has-tooltip--bottom" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" data-tooltip="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" data-tooltip="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="kanban" data-label="Kanban" onclick="switchPanel('kanban')" data-tooltip="Kanban" data-i18n-title="tab_kanban"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16"/><path d="M16 4v16"/><path d="M3 10h18"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" data-tooltip="Skills" data-i18n-title="tab_skills"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" data-tooltip="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" data-tooltip="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" data-tooltip="Agent profiles" data-i18n-title="tab_profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" data-tooltip="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="insights" data-label="Insights" onclick="switchPanel('insights')" data-tooltip="Insights" data-i18n-title="tab_insights"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
<button class="nav-tab active has-tooltip has-tooltip--bottom" data-panel="chat" data-label="Chat" onclick="switchPanel('chat',{fromRailClick:true})" data-tooltip="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks',{fromRailClick:true})" data-tooltip="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="kanban" data-label="Kanban" onclick="switchPanel('kanban',{fromRailClick:true})" data-tooltip="Kanban" data-i18n-title="tab_kanban"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16"/><path d="M16 4v16"/><path d="M3 10h18"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="skills" data-label="Skills" onclick="switchPanel('skills',{fromRailClick:true})" data-tooltip="Skills" data-i18n-title="tab_skills"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="memory" data-label="Memory" onclick="switchPanel('memory',{fromRailClick:true})" data-tooltip="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces',{fromRailClick:true})" data-tooltip="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles',{fromRailClick:true})" data-tooltip="Agent profiles" data-i18n-title="tab_profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="todos" data-label="Todos" onclick="switchPanel('todos',{fromRailClick:true})" data-tooltip="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="insights" data-label="Insights" onclick="switchPanel('insights',{fromRailClick:true})" data-tooltip="Insights" data-i18n-title="tab_insights"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
<button class="nav-tab dashboard-link has-tooltip has-tooltip--bottom" id="dashboardMobileBtn" data-dashboard-link data-label="Dashboard" style="display:none" onclick="openHermesDashboard(event)" data-tooltip="Hermes Dashboard" data-i18n-title="tab_dashboard"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg><span class="dashboard-external-badge" aria-hidden="true"></span></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="logs" data-label="Logs" onclick="switchPanel('logs')" data-tooltip="Logs" data-i18n-title="tab_logs"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="logs" data-label="Logs" onclick="switchPanel('logs',{fromRailClick:true})" data-tooltip="Logs" data-i18n-title="tab_logs"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/></svg></button>
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="settings" onclick="switchPanel('settings')" data-tooltip="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</div>
<!-- Chat panel -->
<div class="panel-view active" id="panelChat">
+23 -1
View File
@@ -183,6 +183,26 @@ function _consumeSettingsTargetPanel(fallback = 'chat') {
async function switchPanel(name, opts = {}) {
const nextPanel = name || 'chat';
const prevPanel = _currentPanel;
// ── Desktop sidebar collapse toggle (rail-click only) ──
// If the click came from a rail icon AND we're on desktop, the rail icon
// does double duty: clicking the already-active panel collapses the sidebar;
// clicking any panel while collapsed expands first. Programmatic switches
// (no opts.fromRailClick) are unaffected so legacy callers preserve
// behaviour exactly.
if (opts.fromRailClick && typeof _isSidebarCollapsed === 'function'
&& typeof _isDesktopWidth === 'function' && _isDesktopWidth()) {
if (_isSidebarCollapsed()) {
// Expand first, then continue to the normal panel switch below so
// the clicked panel becomes (or stays) active in the same gesture.
expandSidebar();
} else if (prevPanel === nextPanel) {
// Same panel clicked while sidebar is open → collapse and short-circuit.
// Skip the guard/cleanup work below; nothing about the active panel
// is changing, only the visibility of the panel container.
toggleSidebar(true);
return false;
}
}
if (!opts.bypassSettingsGuard && !_beforePanelSwitch(nextPanel)) return false;
if (prevPanel !== 'settings' && nextPanel === 'settings') _beginSettingsPanelSession();
// Close any long-lived Kanban SSE stream when leaving the kanban panel
@@ -193,6 +213,8 @@ async function switchPanel(name, opts = {}) {
_currentPanel = nextPanel;
// Update nav tabs (rail + mobile sidebar-nav share data-panel)
document.querySelectorAll('[data-panel]').forEach(t => t.classList.toggle('active', t.dataset.panel === nextPanel));
// Refresh aria-expanded on the newly-active rail button to mirror sidebar state.
if (typeof _syncSidebarAria === 'function') _syncSidebarAria();
// Update panel views
document.querySelectorAll('.panel-view').forEach(p => p.classList.remove('active'));
const panelEl = $('panel' + nextPanel.charAt(0).toUpperCase() + nextPanel.slice(1));
@@ -5978,7 +6000,7 @@ function _clearCronUnreadForJob(jobId){
}
const _origSwitchPanel=switchPanel;
switchPanel=async function(name){ return _origSwitchPanel(name); };
switchPanel=async function(name,opts){ return _origSwitchPanel(name,opts); };
// Start polling on page load
startCronPolling();
+17 -1
View File
@@ -314,7 +314,7 @@
.app-titlebar-hamburger,.app-titlebar-spacer{display:none;width:32px;height:32px;flex-shrink:0;}
.app-titlebar-hamburger{-webkit-app-region:no-drag;align-items:center;justify-content:center;background:none;border:none;color:var(--muted);border-radius:8px;cursor:pointer;padding:0;-webkit-tap-highlight-color:transparent;transition:background-color .15s,color .15s;}
.app-titlebar-hamburger:hover{background:var(--hover-bg);color:var(--text);}
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:visible;flex-shrink:0;}
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:visible;flex-shrink:0;transition:width .24s cubic-bezier(.22,1,.36,1),opacity .18s ease,transform .24s cubic-bezier(.22,1,.36,1),border-color .24s ease;}
.sidebar-header{padding:16px 18px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.logo{width:32px;height:32px;border-radius:9px;background:linear-gradient(145deg,var(--accent-hover),var(--accent));display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px;color:#fff;flex-shrink:0;box-shadow:0 2px 8px var(--accent-bg-strong);}
.sidebar-header h1{font-size:15px;font-weight:600;}
@@ -1316,6 +1316,21 @@
.layout.workspace-panel-collapsed .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
}
/* Sidebar collapse breakpoint matches `_isDesktopWidth()` (min-width:641px) so
clicking the active rail icon in the tablet-portrait band (641900px) actually
produces a visual change rather than silently flipping a class while CSS sits
out at @901. The rail itself becomes visible at min-width:641px, so any width
where the user can click the rail should also be a width where the collapse
rule applies. :not(.mobile-open) excludes the slide-in overlay below 641px. */
@media(min-width:641px){
.layout.sidebar-collapsed .sidebar:not(.mobile-open){width:0 !important;min-width:0;opacity:0;transform:translateX(-14px);border-right-color:transparent;pointer-events:none;overflow:hidden;}
.layout.sidebar-collapsed .sidebar .resize-handle{display:none;}
/* Flash prevention: an inline <script> in index.html sets this dataset on
<html> BEFORE the stylesheet loads, so the collapsed state paints from
frame 0 with zero flicker on cold loads. boot.js clears it once JS owns the state. */
html[data-sidebar-collapsed="1"] .sidebar:not(.mobile-open){width:0 !important;min-width:0;opacity:0;transform:translateX(-14px);border-right-color:transparent;pointer-events:none;overflow:hidden;transition:none;}
}
@media(max-width:900px){
.rightpanel{display:none}
.workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}
@@ -1812,6 +1827,7 @@ body.tts-enabled .msg-tts-btn{display:inline-flex;align-items:center;}
.rightpanel .resize-handle{left:-2px;}
/* Prevent text selection during drag */
body.resizing{user-select:none;cursor:col-resize;}
body.resizing .sidebar{transition:none!important;}
/* ── Tool call cards ── */
/* Running indicator dot (pulsing) */
+59 -6
View File
@@ -153,6 +153,25 @@ def pytest_configure(config):
config.addinivalue_line("markers", "requires_agent_modules: skip when hermes-agent Python modules are not importable")
# ── Disable AWS IMDS probing for the pytest session ────────────────────────
# Background: when hermes-agent's bedrock_adapter / botocore credential chain
# runs during test execution (e.g. provider catalog enumeration triggered by
# api/config.py imports), botocore probes the EC2 Instance Metadata Service at
# 169.254.169.254 looking for an instance role. On VPS hosts where IMDS is
# reachable but rate-limited (HTTP 429) or non-responsive, this dominates wall
# time and turns a 161s test run into 600+s.
#
# Tests have no legitimate reason to call IMDS — the bedrock-related tests use
# explicit mocks or env-var creds. Setting AWS_EC2_METADATA_DISABLED before
# anything imports botocore is the supported way to silence the probe (matches
# the guard the hermes_cli/doctor.py command already uses in its parallel-probe
# block).
#
# Setting this here instead of in a fixture so it lands BEFORE any test-file
# imports trigger botocore initialisation.
os.environ.setdefault("AWS_EC2_METADATA_DISABLED", "true")
# ── Environment isolation for tests ────────────────────────────────────────
# HERMES_WEBUI_SKIP_ONBOARDING is set by hosting providers (e.g. Agent37) and
# by some isolated test harnesses to short-circuit the onboarding wizard.
@@ -304,14 +323,48 @@ def test_server():
# os.environ already set at module level above; no-op here.
env = os.environ.copy()
# Strip real provider keys so test subprocess never inherits production credentials.
# The test server uses a mock/isolated config — no real API calls are made.
# Strip ANY real credential env var so the test subprocess never inherits
# production creds. The test server uses a mock/isolated config — no real
# API calls are made, no real OAuth flow runs, no real cloud SDK should
# ever be initialised with usable credentials.
#
# Without this strip, a stray credential left in the runner's env was
# observed making outbound TLS to a real provider during test runs.
# See investigation notes in pytest-pitfalls SKILL §B.3.
_CRED_ENV_PREFIXES = (
# LLM providers
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL',
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN',
'GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS',
'DEEPSEEK_API_KEY', 'XIAOMI_API_KEY',
'XAI_API_KEY', 'MISTRAL_API_KEY', 'OLLAMA_API_KEY',
'GROQ_API_KEY', 'TOGETHER_API_KEY', 'PERPLEXITY_API_KEY',
'CEREBRAS_API_KEY', 'COHERE_API_KEY', 'FIREWORKS_API_KEY',
'NOUS_API_KEY', 'NOVITA_API_KEY', 'TENCENT_API_KEY',
'BIGMODEL_API_KEY', 'GLM_API_KEY', 'STEPFUN_API_KEY',
'MINIMAX_API_KEY', 'LM_API_KEY', 'LMSTUDIO_API_KEY',
'AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_ENDPOINT',
# AWS — must be stripped or botocore probes IMDS / picks up real creds
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN',
'AWS_PROFILE', 'AWS_BEARER_TOKEN_BEDROCK',
# Memory providers, telemetry, dashboards
'MEM0_API_KEY', 'HONCHO_API_KEY', 'SUPERMEMORY_API_KEY',
# Messaging / gateway
'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', 'SLACK_BOT_TOKEN',
'SIGNAL_API_TOKEN', 'WHATSAPP_API_TOKEN',
# Browser / image-gen / search
'FIRECRAWL_API_KEY', 'FAL_KEY', 'TAVILY_API_KEY',
'SERPER_API_KEY', 'BRAVE_API_KEY',
# Github tokens (PR/issue tools shouldn't be exercised in tests)
'GH_TOKEN', 'GITHUB_TOKEN',
)
for _k in list(env):
if any(_k.startswith(p) for p in (
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY',
'GOOGLE_API_KEY', 'DEEPSEEK_API_KEY', 'XIAOMI_API_KEY',
)):
if any(_k.startswith(p) for p in _CRED_ENV_PREFIXES):
del env[_k]
# Belt-and-suspenders: keep IMDS disabled in the spawn env too (we set it
# at module level above for the pytest process, but make it explicit here
# so it's never accidentally cleared by an env.update later).
env["AWS_EC2_METADATA_DISABLED"] = "true"
env.update({
"HERMES_WEBUI_PORT": str(TEST_PORT),
"HERMES_WEBUI_HOST": "127.0.0.1",
+7 -1
View File
@@ -23,7 +23,13 @@ def _locale_blocks_with_body(i18n_text: str):
def test_kanban_has_native_sidebar_rail_and_mobile_tab():
assert 'data-panel="kanban"' in INDEX
assert 'data-i18n-title="tab_kanban"' in INDEX
assert 'onclick="switchPanel(\'kanban\')"' in INDEX
# Allow either the legacy `switchPanel('kanban')` form or the rail-click-aware
# `switchPanel('kanban',{fromRailClick:true})` form. The sidebar-collapse PR
# added the second-arg opts to all rail buttons so the same-active-icon click
# can toggle the sidebar; legacy callsites elsewhere may still use the bare form.
assert ('onclick="switchPanel(\'kanban\')"' in INDEX
or "onclick=\"switchPanel('kanban',{fromRailClick:true})\"" in INDEX), \
"kanban rail/mobile button must call switchPanel('kanban') (with or without fromRailClick opts)"
assert 'data-label="Kanban"' in INDEX
kanban_section = INDEX[INDEX.find('id="mainKanban"'):INDEX.find('id="mainWorkspaces"')]
assert "<iframe" not in kanban_section.lower()
+337
View File
@@ -0,0 +1,337 @@
"""
Sidebar collapse toggle static regression tests.
Covers the desktop sidebar collapse feature (clicking the already-active rail
button collapses the sidebar panel, or Cmd+B toggles it). Validates the HTML
contract (every rail/sidebar-nav switchPanel call passes fromRailClick:true),
the CSS rules (collapse states, transition, flash-prevention), and the JS
(toggleSidebar / expandSidebar / _isSidebarCollapsed / Cmd+B handler).
Run:
pytest tests/test_sidebar_collapse_toggle.py -v
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
# ── CSS contract ───────────────────────────────────────────────────────────
class TestSidebarCollapseCSS:
"""CSS rules for collapse, flash-prevention, and resize-suppression."""
def test_layout_sidebar_collapsed_rule_exists(self):
assert ".layout.sidebar-collapsed .sidebar" in CSS, \
".layout.sidebar-collapsed .sidebar rule missing from style.css"
def test_collapsed_sets_width_zero(self):
assert "width:0 !important" in CSS or "width:0!important" in CSS, \
"sidebar-collapsed rule must set width:0!important"
def test_collapsed_sets_opacity_zero(self):
# Find the collapsed block and verify opacity:0 is inside it
idx = CSS.index(".layout.sidebar-collapsed .sidebar")
block = CSS[idx:idx + 400]
assert "opacity:0" in block, \
"sidebar-collapsed rule must set opacity:0"
def test_collapsed_uses_negative_translate(self):
idx = CSS.index(".layout.sidebar-collapsed .sidebar")
block = CSS[idx:idx + 400]
assert "translateX(-14px)" in block, \
"Sidebar should slide left when collapsed (mirrors workspace panel)"
def test_collapsed_hides_resize_handle(self):
assert ".layout.sidebar-collapsed .sidebar .resize-handle" in CSS, \
"Resize handle must be hidden when collapsed"
def test_flash_prevention_rule_exists(self):
assert 'html[data-sidebar-collapsed="1"]' in CSS, \
"Flash-prevention rule for html[data-sidebar-collapsed='1'] missing"
def test_flash_prevention_suppresses_transition(self):
idx = CSS.index('html[data-sidebar-collapsed="1"]')
block = CSS[idx:idx + 400]
assert "transition:none" in block, \
"Flash-prevention rule must set transition:none to avoid initial slide"
def test_sidebar_has_transition(self):
# Find the desktop .sidebar rule (the one with width:300px) and verify
# it has the slide transition
m = re.search(r"\.sidebar\{width:300px[^}]*\}", CSS)
assert m, "Desktop .sidebar{width:300px;...} block not found"
assert "transition:" in m.group(0), \
"Desktop .sidebar rule must have a transition for collapse animation"
def test_body_resizing_suppresses_transition(self):
assert "body.resizing .sidebar" in CSS, \
"body.resizing .sidebar rule missing — drag-resize would animate"
idx = CSS.index("body.resizing .sidebar")
block = CSS[idx:idx + 100]
assert "transition:none" in block, \
"body.resizing .sidebar must set transition:none"
def test_mobile_overlay_not_targeted(self):
# Both collapse selectors must exclude .mobile-open so the
# mobile slide-in overlay is never accidentally targeted.
for selector_prefix in (".layout.sidebar-collapsed .sidebar",
'html[data-sidebar-collapsed="1"] .sidebar'):
idx = CSS.index(selector_prefix)
line_end = CSS.index("{", idx)
selector = CSS[idx:line_end]
assert ":not(.mobile-open)" in selector, \
f"Collapse selector must exclude .mobile-open: {selector!r}"
def test_css_breakpoint_matches_js_isdesktopwidth(self):
# The CSS @media block guarding .layout.sidebar-collapsed must use the
# same min-width threshold as JS _isDesktopWidth(). Otherwise a click
# in the asymmetric band silently flips the class while CSS sits out
# — confusing for the user, broken for screen readers.
js_bp = re.search(
r"function\s+_isDesktopWidth[^}]*?matchMedia\('([^']+)'\)",
BOOT_JS, re.DOTALL,
)
assert js_bp, "Could not locate _isDesktopWidth matchMedia query in boot.js"
js_query = js_bp.group(1)
# Walk CSS to find which @media block encloses .layout.sidebar-collapsed
idx = CSS.index(".layout.sidebar-collapsed .sidebar:not(.mobile-open)")
# Search backward for the most recent unmatched `@media(...)`
prefix = CSS[:idx]
depth = 0
media_stack = []
last_open_media = None
i = 0
while i < len(prefix):
ch = prefix[i]
if ch == "@" and prefix[i:i + 6] == "@media":
end = prefix.index("{", i)
cond = prefix[i + 6:end].strip()
media_stack.append((cond, depth + 1))
i = end + 1
depth += 1
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
while media_stack and media_stack[-1][1] > depth:
media_stack.pop()
i += 1
last_open_media = media_stack[-1][0] if media_stack else None
assert last_open_media is not None, (
"Collapse rule must be inside an @media block to gate it correctly"
)
# Normalise whitespace for comparison
norm = lambda s: s.replace(" ", "")
assert norm(last_open_media) == norm(js_query), (
f"CSS @media('{last_open_media}') for .sidebar-collapsed must match JS "
f"_isDesktopWidth() ('{js_query}'). Otherwise clicks in the asymmetric band "
f"silently flip state without visual feedback."
)
# ── boot.js contract ───────────────────────────────────────────────────────
class TestSidebarCollapseBootJS:
"""Functions, constants, and event-handler hooks in boot.js."""
def test_localstorage_key_constant(self):
m = re.search(r"const\s+_SIDEBAR_COLLAPSED_KEY\s*=\s*'([^']*)'", BOOT_JS)
assert m, "_SIDEBAR_COLLAPSED_KEY constant missing from boot.js"
assert m.group(1) == "hermes-webui-sidebar-collapsed", \
f"Unexpected localStorage key: {m.group(1)!r}"
def test_is_desktop_width_function(self):
assert "function _isDesktopWidth" in BOOT_JS, \
"_isDesktopWidth function missing — every collapse path must be desktop-gated"
def test_is_sidebar_collapsed_function(self):
assert "function _isSidebarCollapsed" in BOOT_JS, \
"_isSidebarCollapsed function missing"
def test_toggle_sidebar_function(self):
assert "function toggleSidebar" in BOOT_JS, \
"toggleSidebar function missing"
def test_toggle_sidebar_short_circuits_on_mobile(self):
idx = BOOT_JS.index("function toggleSidebar")
# End of the function: find the next standalone "function " at column 0
end = BOOT_JS.index("\nfunction ", idx + 1)
body = BOOT_JS[idx:end]
assert "_isDesktopWidth()" in body, \
"toggleSidebar must short-circuit on mobile via _isDesktopWidth check"
def test_expand_sidebar_function(self):
assert "function expandSidebar" in BOOT_JS, \
"expandSidebar function missing"
def test_sync_sidebar_aria_function(self):
assert "function _syncSidebarAria" in BOOT_JS, \
"_syncSidebarAria function missing"
def test_aria_uses_active_rail_button(self):
idx = BOOT_JS.index("function _syncSidebarAria")
end = BOOT_JS.index("\nfunction ", idx + 1)
body = BOOT_JS[idx:end]
assert ".rail .rail-btn.nav-tab.active[data-panel]" in body, \
"_syncSidebarAria must target the active rail button"
assert "aria-expanded" in body, \
"_syncSidebarAria must set aria-expanded"
def test_restore_on_boot_iife(self):
assert "_restoreSidebarState" in BOOT_JS, \
"_restoreSidebarState IIFE missing — collapsed state would not persist"
def test_restore_clears_flash_prevention_attribute(self):
# The IIFE must remove the root data-sidebar-collapsed attribute so it
# doesn't override the CSS class system once JS owns the state.
idx = BOOT_JS.index("_restoreSidebarState")
end = BOOT_JS.index("})();", idx) + 5
body = BOOT_JS[idx:end]
assert "removeAttribute('data-sidebar-collapsed')" in body, \
"_restoreSidebarState must clear the data-sidebar-collapsed attribute"
def test_cmd_b_shortcut(self):
# The Cmd/Ctrl+B handler must exist and be gated against text inputs.
# Find it within the global keydown listener.
idx = BOOT_JS.index("document.addEventListener('keydown'")
# The handler is large; search a reasonable window for the shortcut block
window = BOOT_JS[idx:idx + 8000]
assert "metaKey" in window and "ctrlKey" in window and "'b'" in window, \
"Cmd/Ctrl+B handler missing from global keydown listener"
# Must check that target is not an input/textarea/contenteditable
assert "TEXTAREA" in window and "isContentEditable" in window, \
"Cmd/Ctrl+B handler must skip when typing in an input/textarea"
def test_bfcache_pageshow_resync(self):
idx = BOOT_JS.index("window.addEventListener('pageshow'")
# find end of handler
depth = 0
end = BOOT_JS.index("});", idx)
block = BOOT_JS[idx:end + 3]
assert "hermes-webui-sidebar-collapsed" in block, \
"pageshow handler must re-sync sidebar state from localStorage"
assert "_syncSidebarAria" in block, \
"pageshow handler must call _syncSidebarAria after re-sync"
# ── panels.js contract ─────────────────────────────────────────────────────
class TestSwitchPanelGuard:
"""switchPanel() must gate collapse behind opts.fromRailClick."""
def test_from_rail_click_guard(self):
assert "opts.fromRailClick" in PANELS_JS, \
"switchPanel must gate collapse on opts.fromRailClick"
def test_guard_uses_desktop_width(self):
idx = PANELS_JS.index("opts.fromRailClick")
# The fromRailClick branch is at the top of switchPanel — capture ~1KB
block = PANELS_JS[idx:idx + 1500]
assert "_isDesktopWidth" in block, \
"Collapse guard must also check _isDesktopWidth so mobile is excluded"
def test_same_panel_calls_toggle_sidebar(self):
idx = PANELS_JS.index("opts.fromRailClick")
block = PANELS_JS[idx:idx + 1500]
assert "toggleSidebar(true)" in block, \
"Same-panel rail click must call toggleSidebar(true)"
def test_expand_when_collapsed(self):
idx = PANELS_JS.index("opts.fromRailClick")
block = PANELS_JS[idx:idx + 1500]
assert "expandSidebar()" in block, \
"Collapsed-state rail click must call expandSidebar() before switching"
def test_aria_sync_after_panel_switch(self):
# The post-switch aria refresh should be near the data-panel forEach
assert "_syncSidebarAria" in PANELS_JS, \
"panels.js must call _syncSidebarAria after panel switch"
def test_legacy_proxy_forwards_opts(self):
# The proxy at the bottom of the file must forward opts to keep the
# rail-click gesture working when the proxy runs (it overrides the
# function reference, so the original definition is unreachable).
m = re.search(
r"switchPanel\s*=\s*async\s+function\s*\(([^)]*)\)\s*\{[^}]*_origSwitchPanel\(([^)]*)\)",
PANELS_JS
)
assert m, "switchPanel proxy not found at end of panels.js"
params, args = m.group(1), m.group(2)
assert "opts" in params and "opts" in args, \
f"Proxy must forward opts to _origSwitchPanel — got params={params!r}, args={args!r}"
# ── HTML contract ──────────────────────────────────────────────────────────
class TestRailButtonsPassFromRailClick:
"""All rail-button and sidebar-nav switchPanel() calls must opt in."""
def _rail_section(self):
start = HTML.index('<nav class="rail"')
end = HTML.index('</nav>', start)
return HTML[start:end]
def _sidebar_nav_section(self):
start = HTML.index('class="sidebar-nav"')
end = HTML.index('</div>', start)
return HTML[start:end]
def test_all_rail_buttons_pass_from_rail_click(self):
section = self._rail_section()
calls = re.findall(r"switchPanel\('(\w+)'(?:\s*,\s*([^)]*))?\)", section)
assert calls, "No switchPanel() calls found in rail nav (unexpected)"
for panel, args in calls:
assert args and "fromRailClick:true" in args, \
f"Rail button for {panel!r} must pass fromRailClick:true (got: {args!r})"
def test_all_sidebar_nav_buttons_pass_from_rail_click(self):
# sidebar-nav is the mobile mirror; passing fromRailClick is harmless
# because the JS guards on _isDesktopWidth.
section = self._sidebar_nav_section()
calls = re.findall(r"switchPanel\('(\w+)'(?:\s*,\s*([^)]*))?\)", section)
for panel, args in calls:
assert args and "fromRailClick:true" in args, \
f"sidebar-nav button for {panel!r} must pass fromRailClick:true (got: {args!r})"
def test_dashboard_button_unchanged(self):
# Dashboard opens an external page; must NOT pass fromRailClick
assert "openHermesDashboard(event)" in HTML
dash_idx = HTML.index("openHermesDashboard(event)")
# 200-char window before the dashboard onclick should not mention fromRailClick
assert "fromRailClick" not in HTML[dash_idx - 200:dash_idx + 50], \
"Dashboard button should not receive fromRailClick"
# ── Flash-prevention contract ──────────────────────────────────────────────
class TestFlashPreventionScript:
"""The inline <script> in <head> sets data-sidebar-collapsed before CSS."""
def test_inline_script_exists(self):
assert "hermes-webui-sidebar-collapsed" in HTML, \
"Inline flash-prevention script missing from index.html"
def test_inline_script_uses_correct_dataset_key(self):
# The dataset attribute on <html> must match what CSS targets
script_idx = HTML.index("hermes-webui-sidebar-collapsed")
# Find the enclosing <script>...</script>
open_tag = HTML.rfind("<script>", 0, script_idx)
close_tag = HTML.index("</script>", script_idx)
block = HTML[open_tag:close_tag]
assert "dataset.sidebarCollapsed" in block, \
"Inline script must set document.documentElement.dataset.sidebarCollapsed"
def test_inline_script_runs_before_stylesheet(self):
# The script must appear before the main stylesheet <link>
script_idx = HTML.index("hermes-webui-sidebar-collapsed")
css_idx = HTML.index('href="static/style.css')
assert script_idx < css_idx, \
"Flash-prevention script must run before stylesheet to avoid paint flash"