async function cancelStream(){ const streamId = S.activeStreamId; if(!streamId) return; try{ await fetch(new URL(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{credentials:'include'}); const btn=$('btnCancel');if(btn)btn.style.display='none'; setStatus('Cancelling…'); }catch(e){setStatus('Cancel failed: '+e.message);} } // ── Mobile navigation ────────────────────────────────────────────────────── 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'); } function toggleMobileFiles(){ const panel=document.querySelector('.rightpanel'); if(!panel)return; panel.classList.toggle('mobile-open'); } function mobileSwitchPanel(name){ // Close sidebar if open, then switch panel closeMobileSidebar(); // Open sidebar for the selected panel, then close after a moment const sidebar=document.querySelector('.sidebar'); const overlay=$('mobileOverlay'); if(sidebar){ sidebar.classList.add('mobile-open'); if(overlay)overlay.classList.add('visible'); } switchPanel(name); // Update bottom nav active state document.querySelectorAll('.mobile-nav-btn').forEach(btn=>{ btn.classList.toggle('active',btn.dataset.panel===name); }); } $('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();}; $('btnAttach').onclick=()=>$('fileInput').click(); // ── Voice input (Web Speech API) ───────────────────────────────────────── (function(){ const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SpeechRecognition) return; // Browser unsupported — mic button stays hidden const btn=$('btnMic'); const status=$('micStatus'); const ta=$('msg'); btn.style.display=''; // Show button — browser supports speech const recognition=new SpeechRecognition(); recognition.continuous=false; recognition.interimResults=true; recognition.lang='en-US'; let _finalText=''; let _prefix=''; function _setRecording(on){ window._micActive=on; btn.classList.toggle('recording',on); status.style.display=on?'':'none'; if(!on){ _finalText=''; _prefix=''; } } recognition.onstart=()=>{ _finalText=''; }; recognition.onresult=(event)=>{ let interim=''; let final=_finalText; for(let i=event.resultIndex;i{ // Commit: prefix + final transcription; trim trailing space if prefix was non-empty const committed=_finalText ? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n') ? _prefix+' '+_finalText.trimStart() : _prefix+_finalText) : ta.value; // no speech detected — leave whatever is there _setRecording(false); ta.value=committed; autoResize(); }; recognition.onerror=(event)=>{ _setRecording(false); const msgs={ 'not-allowed':'Microphone access denied. Check browser permissions.', 'no-speech':'No speech detected. Try again.', 'network':'Speech recognition unavailable.', }; showToast(msgs[event.error]||'Voice input error: '+event.error); }; function _stopMic(){ if(window._micActive){ recognition.stop(); } } window._stopMic=_stopMic; // expose for send-guard above btn.onclick=()=>{ if(window._micActive){ recognition.stop(); // _setRecording(false) will be called by onend } else { _finalText=''; // Snapshot existing textarea content so we append rather than replace _prefix=ta.value; recognition.start(); _setRecording(true); } }; })(); window._micActive=window._micActive||false; $('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';}; $('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();}; $('btnDownload').onclick=()=>{ if(!S.session)return; const blob=new Blob([transcript()],{type:'text/markdown'}); const a=document.createElement('a');a.href=URL.createObjectURL(blob); a.download=`hermes-${S.session.session_id}.md`;a.click();URL.revokeObjectURL(a.href); }; $('btnExportJSON').onclick=()=>{ if(!S.session)return; const url=`/api/session/export?session_id=${encodeURIComponent(S.session.session_id)}`; const a=document.createElement('a');a.href=url; a.download=`hermes-${S.session.session_id}.json`;a.click(); }; $('btnImportJSON').onclick=()=>$('importFileInput').click(); $('importFileInput').onchange=async(e)=>{ const file=e.target.files[0]; if(!file)return; e.target.value=''; try{ const text=await file.text(); const data=JSON.parse(text); const res=await api('/api/session/import',{method:'POST',body:JSON.stringify(data)}); if(res.ok&&res.session){ await loadSession(res.session.session_id); await renderSessionList(); showToast('Session imported'); } }catch(err){ showToast('Import failed: '+(err.message||'Invalid JSON')); } }; // btnRefreshFiles is now panel-icon-btn in header (see HTML) function clearPreview(){ const pa=$('previewArea');if(pa)pa.classList.remove('visible'); const pi=$('previewImg');if(pi)pi.src=''; const pm=$('previewMd');if(pm)pm.innerHTML=''; const pc=$('previewCode');if(pc)pc.textContent=''; const pp=$('previewPathText');if(pp)pp.textContent=''; const ft=$('fileTree');if(ft)ft.style.display=''; _previewCurrentPath='';_previewCurrentMode='';_previewDirty=false; } $('btnClearPreview').onclick=clearPreview; // workspacePath click handler removed -- use topbar workspace chip dropdown instead $('modelSelect').onchange=async()=>{ if(!S.session)return; const selectedModel=$('modelSelect').value; localStorage.setItem('hermes-webui-model', selectedModel); await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})}); S.session.model=selectedModel;syncTopbar(); }; $('msg').addEventListener('input',()=>{ autoResize(); updateSendBtn(); const text=$('msg').value; if(text.startsWith('/')&&text.indexOf('\n')===-1){ const prefix=text.slice(1); const matches=getMatchingCommands(prefix); if(matches.length)showCmdDropdown(matches); else hideCmdDropdown(); } else { hideCmdDropdown(); } }); $('msg').addEventListener('keydown',e=>{ // Autocomplete navigation when dropdown is open const dd=$('cmdDropdown'); const dropdownOpen=dd&&dd.classList.contains('open'); if(dropdownOpen){ if(e.key==='ArrowUp'){e.preventDefault();navigateCmdDropdown(-1);return;} if(e.key==='ArrowDown'){e.preventDefault();navigateCmdDropdown(1);return;} if(e.key==='Tab'){e.preventDefault();selectCmdDropdownItem();return;} if(e.key==='Escape'){e.preventDefault();hideCmdDropdown();return;} if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();selectCmdDropdownItem();return;} } // Send key: respect user preference if(e.key==='Enter'){ if(window._sendKey==='ctrl+enter'){ if(e.ctrlKey||e.metaKey){e.preventDefault();send();} } else { if(!e.shiftKey){e.preventDefault();send();} } } }); // B14: Cmd/Ctrl+K creates a new chat from anywhere document.addEventListener('keydown',async e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='k'){ e.preventDefault(); if(!S.busy){await newSession();await renderSessionList();$('msg').focus();} } if(e.key==='Escape'){ // Close settings overlay if open const settingsOverlay=$('settingsOverlay'); if(settingsOverlay&&settingsOverlay.style.display!=='none'){toggleSettings();return;} // Close workspace dropdown closeWsDropdown(); // Clear session search const ss=$('sessionSearch'); if(ss&&ss.value){ss.value='';filterSessions();} // Cancel any active message edit const editArea=document.querySelector('.msg-edit-area'); if(editArea){ const bar=editArea.closest('.msg-row')&&editArea.closest('.msg-row').querySelector('.msg-edit-bar'); if(bar){const cancel=bar.querySelector('.msg-edit-cancel');if(cancel)cancel.click();} } } }); $('msg').addEventListener('paste',e=>{ const items=Array.from(e.clipboardData?.items||[]); const imageItems=items.filter(i=>i.type.startsWith('image/')); if(!imageItems.length)return; e.preventDefault(); const files=imageItems.map(i=>{ const blob=i.getAsFile(); const ext=i.type.split('/')[1]||'png'; return new File([blob],`screenshot-${Date.now()}.${ext}`,{type:i.type}); }); addFiles(files); setStatus(`Image pasted: ${files.map(f=>f.name).join(', ')}`); }); document.querySelectorAll('.suggestion').forEach(btn=>{ btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();}; }); // Boot: restore last session or start fresh // ── Resizable panels ────────────────────────────────────────────────────── (function(){ const SIDEBAR_MIN=180, SIDEBAR_MAX=420; const PANEL_MIN=180, PANEL_MAX=500; function initResize(handleId, targetEl, edge, minW, maxW, storageKey){ const handle = $(handleId); if(!handle || !targetEl) return; // Restore saved width const saved = localStorage.getItem(storageKey); if(saved) targetEl.style.width = saved + 'px'; let startX=0, startW=0; handle.addEventListener('mousedown', e=>{ e.preventDefault(); startX = e.clientX; startW = targetEl.getBoundingClientRect().width; handle.classList.add('dragging'); document.body.classList.add('resizing'); const onMove = ev=>{ const delta = edge==='right' ? ev.clientX - startX : startX - ev.clientX; const newW = Math.min(maxW, Math.max(minW, startW + delta)); targetEl.style.width = newW + 'px'; }; const onUp = ()=>{ handle.classList.remove('dragging'); document.body.classList.remove('resizing'); localStorage.setItem(storageKey, parseInt(targetEl.style.width)); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); } // Run after DOM ready (called from boot) window._initResizePanels = function(){ const sidebar = document.querySelector('.sidebar'); const rightpanel = document.querySelector('.rightpanel'); initResize('sidebarResize', sidebar, 'right', SIDEBAR_MIN, SIDEBAR_MAX, 'hermes-sidebar-w'); initResize('rightpanelResize', rightpanel, 'left', PANEL_MIN, PANEL_MAX, 'hermes-panel-w'); }; })(); (async()=>{ // Load send key preference try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';}catch(e){window._sendKey='enter';} // Fetch available models from server and populate dropdown dynamically await populateModelDropdown(); // Restore last-used model preference const savedModel=localStorage.getItem('hermes-webui-model'); if(savedModel && $('modelSelect')){ $('modelSelect').value=savedModel; // If the value didn't take (model not in list), clear the bad pref if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model'); } // Pre-load workspace list so sidebar name is correct from first render await loadWorkspaceList(); _initResizePanels(); const saved=localStorage.getItem('hermes-webui-session'); if(saved){ try{await loadSession(saved);await renderSessionList();await checkInflightOnBoot(saved);return;} catch(e){localStorage.removeItem('hermes-webui-session');} } // no saved session - show empty state, wait for user to hit + $('emptyState').style.display=''; await renderSessionList(); })();