feat(ux): collapse sidebar by clicking the active rail icon (fuses #1884 + #1924)

Lets desktop users collapse the session-list sidebar to maximise the chat
area, without adding any visible UI affordance. Default appearance is
identical to master — only users who actively try to toggle (or know the
keyboard shortcut) ever see a difference.

## Behaviour (desktop only, ≥641px)

| State                              | Action                | Result                                  |
|------------------------------------|-----------------------|-----------------------------------------|
| Sidebar open, click active rail    | Toggle                | Sidebar collapses to width:0            |
| Sidebar open, click different rail | Normal switch         | **Sidebar stays open** (no surprise)    |
| Sidebar collapsed, click any rail  | Expand + switch       | Sidebar expands, then panel switches    |
| Anywhere, Cmd/Ctrl+B               | Toggle                | Same as same-active-rail click          |
| Mobile (<641px), any of the above  | No-op                 | Mobile overlay behaviour unchanged       |

Two discoverability paths, both opt-in. **No new visible buttons.** Users
who never click the active rail icon see zero UI change vs. master.

## Surface-minimal design

The behaviour is contained behind one extra arg on the rail/sidebar-nav
onclick: `switchPanel('chat',{fromRailClick:true})`. Without that flag the
function preserves master's behaviour exactly — every programmatic
`switchPanel(name)` callsite (commands, deeplinks, internal state changes)
is unaffected. The guard chain inside `switchPanel`:

  opts.fromRailClick && _isDesktopWidth() && (
      _isSidebarCollapsed() ? expandSidebar() :
      prevPanel === nextPanel ? (toggleSidebar(true); return false))

is the ONLY new code path that can cause a collapse. Cross-panel clicks
fall through to the existing switch logic untouched.

## Polish from both source PRs

- **Click-active gesture** as the primary toggle (#1884 @jasonjcwu — the
  genuine UX innovation; no extra button needed)
- **Cmd/Ctrl+B keyboard shortcut** (#1924 @spektro33; VS Code convention).
  Guarded against firing when typing in INPUT / TEXTAREA / contenteditable
  so the shortcut never steals from in-progress text editing.
- **Inline flash-prevention `<script>`** in `<head>` (#1924) sets
  `data-sidebar-collapsed='1'` on `<html>` BEFORE the stylesheet loads,
  so cold loads with a persisted-collapsed state paint correctly from
  frame 0 with no flicker. Cleared by JS once the class system takes over.
- **Smooth slide animation** via `.24s cubic-bezier(.22,1,.36,1)`
  (#1924, mirrors the existing workspace-panel collapse on the right)
- **`aria-expanded` mirrored** on the active rail button (#1884) so
  screen readers announce open/collapsed transitions.
- **`body.resizing` transition-suppression** (#1884) keeps the drag-resize
  cursor instant — no animation during a width-resize gesture.
- **bfcache `pageshow` re-sync** (#1884) — if another tab toggled the
  sidebar while this page was frozen, bring it in line on restore.

## Drops vs. #1924

- No persistent rail "toggle sidebar" button (Nathan: keep the UI stealth)
- No close-X button in chat panel head (same reason)
- No i18n keys for the dropped buttons

## What did NOT change

- 22 rail/sidebar-nav `onclick` handlers gained the `{fromRailClick:true}`
  arg — function-call shape, invisible to users
- 1 inline `<script>` in `<head>` (flash prevention) — invisible
- 5 lines of CSS — invisible unless someone collapses

That's the entire visible-UI delta. **23 ins / 22 del on `index.html`,
all string-replace.**

## Verification

- 5,151 pytest passing including a new 34-test structural suite covering
  every contract (CSS rules, JS functions, fromRailClick guard, legacy
  proxy forwarding, flash-prevention `<script>` ordering, mobile
  exclusion via :not(.mobile-open) selector, aria-expanded sync).

- Live browser walkthrough at 1280px verified:
  - Default boot state identical to master (sidebar open, width 300px)
  - Click active rail → collapse (width 1, opacity 0, translateX -14px,
    localStorage='1', aria-expanded=false). Panel unchanged.
  - Click active rail again → expand back to width 300, aria=true
  - Click DIFFERENT rail → normal switch, sidebar stays open (legacy-
    preserving case, verified explicitly)
  - Click rail while collapsed → expand + switch in one gesture
  - Cmd+B toggles correctly
  - Cmd+B inside `<textarea>` → suppressed (defaultPrevented=false)
  - Reload with collapsed state persisted → restores without flash
  - Mobile simulation (matchMedia returns false for min-width:641px):
    same-active-rail click is no-op, Cmd+B is no-op, sidebar stays at 300px

Co-authored-by: jasonjcwu <jasonjcwu@users.noreply.github.com>
Co-authored-by: spektro33 <spektro33@users.noreply.github.com>
Closes #1884
Closes #1924
This commit is contained in:
nesquena-hermes
2026-05-11 04:49:18 +00:00
parent 9c9d65a41a
commit 2dbee503c2
6 changed files with 431 additions and 25 deletions
+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();
+11 -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;}
@@ -1314,6 +1314,15 @@
@media(min-width:901px){
html[data-workspace-panel="closed"] .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
.layout.workspace-panel-collapsed .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
/* Sidebar collapse same shape as workspace panel collapse, mirrored on the left.
:not(.mobile-open) so the mobile slide-in overlay (handled in the max-width:640px
block above) is never targeted by these rules. */
.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){
@@ -1812,6 +1821,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) */
+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()
+289
View File
@@ -0,0 +1,289 @@
"""
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}"
# ── 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"