mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 03:30:36 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 (641–900px) 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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user