(function(){ // Clear stale stop-server flag on successful page load (server is reachable) localStorage.removeItem('hermes-webui-server-stopped'); // Listen for shutdown broadcast from other tabs try { var _stopChan = new BroadcastChannel('hermes-webui-shutdown'); _stopChan.onmessage = function() { _showServerStopped(); }; } catch(_) {} })(); async function cancelStream(){ const streamId = S.activeStreamId; if(!streamId) return; try{ await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'}); }catch(e){/* cancel request failed - cleanup below still runs */} // Clear status unconditionally after the cancel request completes. // The SSE cancel event may also fire, but if the connection is already // closed it won't arrive — so we handle cleanup here as the guaranteed path. S.activeStreamId=null; setBusy(false); if(typeof setComposerStatus==='function') setComposerStatus(''); else setStatus(''); } async function cancelSessionStream(session){ const streamId = session&&session.active_stream_id; const sid = session&&session.session_id; if(!streamId||!sid) return; try{ await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'}); }catch(e){/* cancel request failed - cleanup below still runs */} session.active_stream_id=null; delete INFLIGHT[sid]; clearInflightState(sid); if(S.session&&S.session.session_id===sid){ S.activeStreamId=null; if(S.session) S.session.active_stream_id=null; clearInflight(); setBusy(false); if(typeof setComposerStatus==='function') setComposerStatus(''); else setStatus(''); } if(typeof _approvalSessionId!=='undefined' && _approvalSessionId===sid){ stopApprovalPolling(); hideApprovalCard(true); } if(typeof _clarifySessionId!=='undefined' && _clarifySessionId===sid){ stopClarifyPolling(); hideClarifyCard(true, 'cancelled'); } if(typeof renderSessionList==='function') renderSessionList(); } async function _savedSessionShouldStaySidebarOnly(sid){ if(!sid) return false; try{ const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`); const session = data&&data.session; return !!(session&&(session.active_stream_id||session.pending_user_message)); }catch(e){ return false; } } // ── Mobile navigation ────────────────────────────────────────────────────── let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview' function _isCompactWorkspaceViewport(){ return window.matchMedia('(max-width: 900px)').matches; } function _syncWorkspacePanelInlineWidth(){ const {panel}= _workspacePanelEls(); if(!panel) return; const isCompact = _isCompactWorkspaceViewport(); if(isCompact){ if(panel.style.width) panel.style.removeProperty('width'); return; } const saved = localStorage.getItem('hermes-panel-w'); if(!saved) return; const parsed = parseInt(saved, 10); if(Number.isNaN(parsed) || parsed <= 0) return; panel.style.width = `${parsed}px`; } function _workspacePanelEls(){ return { layout: document.querySelector('.layout'), panel: document.querySelector('.rightpanel'), toggleBtn: $('btnWorkspacePanelToggle'), edgeToggleBtn: $('btnWorkspacePanelEdgeToggle'), collapseBtn: $('btnCollapseWorkspacePanel'), }; } function _hasWorkspacePreviewVisible(){ const preview=$('previewArea'); return !!(preview&&preview.classList.contains('visible')); } function _setWorkspacePanelMode(mode){ const {layout,panel}= _workspacePanelEls(); if(!layout||!panel)return; _workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed'; const open=_workspacePanelMode!=='closed'; document.documentElement.dataset.workspacePanel=open?'open':'closed'; // Persist open/closed across refreshes (browse/preview → open; closed → closed) // Do NOT overwrite the user's "keep open" preference — only track runtime state // so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting. try{localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');}catch(_){} layout.classList.toggle('workspace-panel-collapsed',!open); if(_isCompactWorkspaceViewport()){ panel.classList.toggle('mobile-open',open); }else{ panel.classList.remove('mobile-open'); } syncWorkspacePanelUI(); } function syncWorkspacePanelState(){ const hasPreview=_hasWorkspacePreviewVisible(); if(hasPreview){ if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview'); else syncWorkspacePanelUI(); return; } if(!S.session){ // No active session — if the panel was explicitly opened (browse mode), keep it // open so the workspace pane doesn't vanish on a fresh-page or empty-session boot. // The file tree will show the "no workspace" placeholder naturally via renderFileTree(). // Only force-close if the mode is 'preview' (file preview without a session is invalid). if(_workspacePanelMode==='preview') _setWorkspacePanelMode('closed'); else syncWorkspacePanelUI(); return; } _setWorkspacePanelMode(_workspacePanelMode==='preview'?'closed':_workspacePanelMode); } function openWorkspacePanel(mode='browse'){ if(mode==='browse'&&!S.session&&!_hasWorkspacePreviewVisible()&&!S._profileDefaultWorkspace)return; if(mode==='preview'&&_workspacePanelMode==='browse'){ syncWorkspacePanelUI(); return; } _setWorkspacePanelMode(mode); } function closeWorkspacePanel(){ _setWorkspacePanelMode('closed'); } function ensureWorkspacePreviewVisible(){ if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview'); else syncWorkspacePanelUI(); } function handleWorkspaceClose(){ if(_hasWorkspacePreviewVisible()){ clearPreview(); return; } closeWorkspacePanel(); } /** * Set a tooltip on a button, preferring the custom CSS tooltip (`data-tooltip`) * when the element opts in via the `has-tooltip` class. Falls back to the * native `title` attribute for elements that haven't opted in. * * Critical: when the element DOES have data-tooltip, this MUST also clear any * existing native `title` attribute, otherwise the slow ~1.5s native browser * tooltip co-fires alongside the fast custom CSS tooltip — exactly the bug * #1775 reports. Always pair `data-tooltip` with `removeAttribute('title')`. */ function _setButtonTooltip(btn, text){ if(!btn) return; if(btn.hasAttribute('data-tooltip')){ btn.setAttribute('data-tooltip', text); if(btn.hasAttribute('title')) btn.removeAttribute('title'); } else { btn.title = text; } } function syncWorkspacePanelUI(){ const {layout,panel,toggleBtn,edgeToggleBtn,collapseBtn}= _workspacePanelEls(); if(!layout||!panel)return; const desktopOpen=_workspacePanelMode!=='closed'; const mobileOpen=panel.classList.contains('mobile-open'); const isCompact=_isCompactWorkspaceViewport(); const isOpen=isCompact?mobileOpen:desktopOpen; const canBrowse=!!S.session||_hasWorkspacePreviewVisible()||!!(S._profileDefaultWorkspace); const hasPreview=_hasWorkspacePreviewVisible(); if(toggleBtn){ toggleBtn.classList.toggle('active',isOpen); toggleBtn.setAttribute('aria-pressed',isOpen?'true':'false'); _setButtonTooltip(toggleBtn, isOpen?'Hide workspace panel':'Show workspace panel'); toggleBtn.disabled=!canBrowse; } if(edgeToggleBtn){ edgeToggleBtn.classList.toggle('active',isOpen); edgeToggleBtn.setAttribute('aria-expanded',isOpen?'true':'false'); _setButtonTooltip(edgeToggleBtn, isOpen?'Hide workspace panel':'Show workspace panel'); edgeToggleBtn.disabled=!canBrowse; } if(collapseBtn){ _setButtonTooltip(collapseBtn, isCompact?'Close workspace panel':'Hide workspace panel'); } const hasSession=!!S.session; ['btnUpDir','btnNewFile','btnNewFolder','btnRefreshPanel'].forEach(id=>{ const el=$(id); if(el)el.disabled=!hasSession; }); const clearBtn=$('btnClearPreview'); if(clearBtn){ clearBtn.disabled=!isOpen; _setButtonTooltip(clearBtn, hasPreview?'Close preview':'Close'); if(!isCompact) clearBtn.style.display=''; } } function toggleMobileSidebar(){ const sidebar=document.querySelector('.sidebar'); const overlay=$('mobileOverlay'); if(!sidebar)return; const isOpen=sidebar.classList.contains('mobile-open'); if(isOpen){closeMobileSidebar();} else{sidebar.classList.add('mobile-open');if(overlay)overlay.classList.add('visible');} } function closeMobileSidebar(){ const sidebar=document.querySelector('.sidebar'); const overlay=$('mobileOverlay'); if(sidebar)sidebar.classList.remove('mobile-open'); if(overlay)overlay.classList.remove('visible'); } const _PWA_SIDEBAR_SWIPE_EDGE=28; const _PWA_SIDEBAR_SWIPE_TRIGGER=72; const _PWA_SIDEBAR_SWIPE_MAX_VERTICAL=48; let _pwaSidebarSwipe=null; function _isPwaStandalone(){ try{ return document.documentElement.classList.contains('pwa-standalone') || window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone===true; }catch(_){return false;} } function _isInteractiveSwipeTarget(target){ try{return !!(target&&target.closest&&target.closest('input,textarea,select,button,a,[contenteditable="true"],.topbar-chips,.composer-left,.sidebar,.rightpanel'));} catch(_){return false;} } function _openMobileSidebarFromGesture(){ if(_isDesktopWidth())return; const sidebar=document.querySelector('.sidebar'); const overlay=$('mobileOverlay'); if(!sidebar)return; const layout=document.querySelector('.layout'); if(layout)layout.classList.remove('sidebar-collapsed'); sidebar.classList.remove('sidebar-collapsed'); try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){} sidebar.classList.add('mobile-open'); if(overlay)overlay.classList.add('visible'); } function _onPwaSidebarSwipeStart(e){ if(!_isPwaStandalone()||_isDesktopWidth())return; if(e.pointerType==='mouse'||(e.pointerType&&e.pointerType!=='touch'&&e.pointerType!=='pen'))return; if(document.querySelector('.sidebar')?.classList.contains('mobile-open'))return; const clientX=Number(e.clientX)||0; if(clientX>_PWA_SIDEBAR_SWIPE_EDGE)return; if(_isInteractiveSwipeTarget(e.target))return; _pwaSidebarSwipe={startX:clientX,startY:Number(e.clientY)||0,active:true,opened:false}; } function _onPwaSidebarSwipeMove(e){ const swipe=_pwaSidebarSwipe; if(!swipe||!swipe.active||swipe.opened)return; const dx=(Number(e.clientX)||0)-swipe.startX; const dy=(Number(e.clientY)||0)-swipe.startY; if(dx<0||Math.abs(dy)>_PWA_SIDEBAR_SWIPE_MAX_VERTICAL*1.5){_pwaSidebarSwipe=null;return;} if(dx>=_PWA_SIDEBAR_SWIPE_TRIGGER&&Math.abs(dy)<=_PWA_SIDEBAR_SWIPE_MAX_VERTICAL&&dx>Math.abs(dy)*1.5){ if(e.cancelable)e.preventDefault(); swipe.opened=true; _openMobileSidebarFromGesture(); } } function _onPwaSidebarSwipeEnd(){_pwaSidebarSwipe=null;} function _onPwaSidebarSwipeCancel(){_pwaSidebarSwipe=null;} function _installPwaSidebarSwipeGesture(){ window.addEventListener('pointerdown', _onPwaSidebarSwipeStart, {passive:true}); window.addEventListener('pointermove', _onPwaSidebarSwipeMove, {passive:false}); window.addEventListener('pointerup', _onPwaSidebarSwipeEnd, {passive:true}); window.addEventListener('pointercancel', _onPwaSidebarSwipeCancel, {passive:true}); } _installPwaSidebarSwipeGesture(); // ── 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 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(); })(); // ── Boot-time tab visibility ──────────────────────────────────────────────── // Apply hidden tabs from localStorage. The primary flash-prevention is an // inline