Files
2026-05-13 16:00:46 -07:00

1610 lines
66 KiB
JavaScript

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'),
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.
localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');
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,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(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':'Hide workspace panel');
// On desktop, only show the X button when a file preview is open.
// In browse mode the chevron (btnCollapseWorkspacePanel) already serves
// as the close control, so showing both produces a duplicate X.
if(!isCompact) clearBtn.style.display=hasPreview?'':'none';
}
}
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');
}
// ── 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();
}
function toggleWorkspacePanel(force){
const {panel}= _workspacePanelEls();
if(!panel)return;
const currentlyOpen=_workspacePanelMode!=='closed';
const nextOpen=typeof force==='boolean'?force:!currentlyOpen;
if(!nextOpen){
closeWorkspacePanel();
return;
}
const nextMode=_hasWorkspacePreviewVisible()?'preview':'browse';
openWorkspacePanel(nextMode);
}
function mobileSwitchPanel(name){
switchPanel(name);
if(name==='chat'){
closeMobileSidebar();
} else {
const sidebar=document.querySelector('.sidebar');
const overlay=$('mobileOverlay');
if(sidebar){
sidebar.classList.add('mobile-open');
if(overlay)overlay.classList.add('visible');
}
}
}
$('btnSend').onclick=()=>{
if(typeof handleComposerPrimaryAction==='function') return handleComposerPrimaryAction();
if(window._micActive){
window._micPendingSend=true;
_stopMic();
return;
}
// Turn-based voice mode: let the voice mode system handle the send flow
if(typeof window._voiceModeActive==='function'&&window._voiceModeActive()){
// Immediately send whatever is in the textarea
if(typeof window._voiceModeImmediateSend==='function') window._voiceModeImmediateSend();
return;
}
send();
};
$('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInput').value='';$('fileInput').click();};
// ── Voice input (Web Speech API + MediaRecorder fallback) ───────────────────
(function(){
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder);
if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden
// Persist SR failure across reloads (e.g. Tailscale/network error)
const _micForceMediaRecorderKey='mic_force_mediarecorder';
let _forceMediaRecorder=!SpeechRecognition||localStorage.getItem(_micForceMediaRecorderKey)==='1';
const btn=$('btnMic');
const status=$('micStatus');
const ta=$('msg');
const statusText=status?status.querySelector('.status-text'):null;
btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
let recognition=(!_forceMediaRecorder&&SpeechRecognition)?new SpeechRecognition():null;
let mediaRecorder=null;
let mediaStream=null;
let audioChunks=[];
let _finalText='';
let _prefix='';
let _isRecording=false;
function _setRecording(on){
window._micActive=on;
btn.classList.toggle('recording',on);
// Active-state title flips so the tooltip is honest about what
// pressing the button will do (#1488).
_setButtonTooltip(btn, on ? t('voice_dictate_active') : t('voice_dictate'));
status.style.display=on?'':'none';
if(statusText) statusText.textContent=on?'Listening':'Listening';
if(!on){ _finalText=''; _prefix=''; }
}
function _commitTranscript(text){
const clean=(text||'').trim();
const committed=clean
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
? _prefix+' '+clean.trimStart()
: _prefix+clean)
: ta.value;
ta.value=committed;
autoResize();
if(window._micPendingSend){
window._micPendingSend=false;
send();
}
}
async function _transcribeBlob(blob){
const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
const form=new FormData();
form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`}));
setComposerStatus('Transcribing…');
try{
const res=await fetch('api/transcribe',{method:'POST',body:form});
const data=await res.json().catch(()=>({}));
if(!res.ok) throw new Error(data.error||'Transcription failed');
_commitTranscript(data.transcript||'');
}catch(err){
window._micPendingSend=false;
showToast(err.message||t('mic_network'));
}finally{
setComposerStatus('');
}
}
function _stopTracks(){
if(mediaStream){
mediaStream.getTracks().forEach(track=>track.stop());
mediaStream=null;
}
}
function _stopMic(){
if(!window._micActive) return;
if(recognition){
recognition.stop();
return;
}
if(mediaRecorder&&mediaRecorder.state!=='inactive'){
mediaRecorder.stop();
return;
}
_setRecording(false);
_stopTracks();
}
window._stopMic=_stopMic; // expose for send-guard above
if(recognition && !_forceMediaRecorder){
recognition.continuous=false;
recognition.interimResults=true;
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
recognition.onstart=()=>{ _finalText=''; };
recognition.onresult=(event)=>{
let interim='';
let final=_finalText;
for(let i=event.resultIndex;i<event.results.length;i++){
const t=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=t; _finalText=final; }
else{ interim+=t; }
}
ta.value=_prefix+(final||interim);
autoResize();
};
recognition.onend=()=>{
const committed=_finalText
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
? _prefix+' '+_finalText.trimStart()
: _prefix+_finalText)
: ta.value;
_setRecording(false);
ta.value=committed;
autoResize();
if(window._micPendingSend){
window._micPendingSend=false;
send();
}
};
recognition.onerror=(event)=>{
_setRecording(false);
window._micPendingSend=false;
_isRecording=false;
if(event.error==='network'||event.error==='not-allowed'){
// Persist SR failure: next reload will skip SpeechRecognition
localStorage.setItem(_micForceMediaRecorderKey,'1');
_forceMediaRecorder=true;
recognition=null;
}
const msgs={
'not-allowed':t('mic_denied'),
'no-speech':t('mic_no_speech'),
'network':t('mic_network'),
};
showToast(msgs[event.error]||t('mic_error')+event.error);
};
}
btn.onclick=async()=>{
// Race-condition guard: ignore rapid double-clicks
if(_isRecording){
_stopMic();
_isRecording=false;
return;
}
if(window._micActive){
_stopMic();
return;
}
_isRecording=true;
_finalText='';
_prefix=ta.value;
if(recognition && !_forceMediaRecorder){
recognition.start();
_setRecording(true);
return;
}
if(!_canRecordAudio){
_isRecording=false;
showToast(t('mic_network'));
return;
}
try{
mediaStream=await navigator.mediaDevices.getUserMedia({audio:true});
const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg'];
const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||'';
mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined);
audioChunks=[];
mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);};
mediaRecorder.onerror=()=>{
_isRecording=false;
_setRecording(false);
window._micPendingSend=false;
_stopTracks();
showToast(t('mic_network'));
};
mediaRecorder.onstop=async()=>{
_isRecording=false;
const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'});
_setRecording(false);
_stopTracks();
if(blob.size){ await _transcribeBlob(blob); }
else if(window._micPendingSend){
window._micPendingSend=false;
}
};
mediaRecorder.start();
_setRecording(true);
}catch(err){
_isRecording=false;
window._micPendingSend=false;
_stopTracks();
showToast(t('mic_denied'));
}
};
})();
window._micActive=window._micActive||false;
window._micPendingSend=window._micPendingSend||false;
// ── Turn-based voice mode (#1333) ────────────────────────────────────────
// Chained flow: listen → send → (agent processes) → TTS response → listen again
(function(){
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
const hasSTT=!(!SpeechRecognition);
const hasTTS=!!('speechSynthesis' in window);
// Need both STT and TTS for turn-based voice mode
if(!hasSTT||!hasTTS) return;
const modeBtn=$('btnVoiceMode');
const bar=$('voiceModeBar');
const indicator=$('voiceModeIndicator');
const label=$('voiceModeLabel');
const micBtn=$('btnMic');
const ta=$('msg');
if(!modeBtn||!bar||!indicator||!label) return;
// Voice-mode button is gated behind a Preferences toggle (#1488).
// Default off — keeps the composer footer uncluttered for users who
// only need plain dictation. The hands-free conversation feature is
// a power-user surface; explicit opt-in avoids the visual confusion
// of two near-identical mic icons.
function _voiceModePrefEnabled(){
try{ return localStorage.getItem('hermes-voice-mode-button')==='true'; }
catch(_){ return false; }
}
let _voiceModeActive=false;
function _applyVoiceModePref(){
const enabled = _voiceModePrefEnabled();
modeBtn.style.display = enabled ? '' : 'none';
if(!enabled && _voiceModeActive) _deactivate();
}
_applyVoiceModePref();
// Expose so the settings pane can re-apply immediately on toggle.
window._applyVoiceModePref = _applyVoiceModePref;
let _voiceModeState='idle'; // idle | listening | thinking | speaking
let _recognition=null;
let _silenceTimer=null;
// Capture the session id at thinking-time so the TTS callback won't read
// a different session's last assistant reply if the user navigated away
// between send and stream completion. (Opus pre-release advisor.)
let _voiceModeThinkingSid=null;
const SILENCE_MS=1800; // auto-send after 1.8s silence
function _setState(state){
_voiceModeState=state;
indicator.className='voice-mode-indicator '+state;
label.textContent=state==='listening'?t('voice_listening')
:state==='speaking'?t('voice_speaking')
:state==='thinking'?t('voice_thinking')
:'';
bar.style.display=_voiceModeActive?(state==='idle'?'none':''):'none';
}
function _startListening(){
if(!_voiceModeActive) return;
_setState('listening');
_recognition=new SpeechRecognition();
_recognition.continuous=false;
_recognition.interimResults=true;
_recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
let _finalText='';
_recognition.onstart=()=>{ _finalText=''; };
_recognition.onresult=(event)=>{
// Reset silence timer on any result
clearTimeout(_silenceTimer);
let interim='';
let final=_finalText;
for(let i=event.resultIndex;i<event.results.length;i++){
const txt=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=txt; _finalText=final; }
else{ interim+=txt; }
}
ta.value=final||interim;
autoResize();
// Auto-send on silence after final result
if(_finalText){
_silenceTimer=setTimeout(()=>{
_voiceModeSend();
},SILENCE_MS);
}
};
_recognition.onend=()=>{
clearTimeout(_silenceTimer);
// If we have text and haven't sent yet, send it
if(_finalText&&_voiceModeActive&&_voiceModeState==='listening'){
_voiceModeSend();
} else if(_voiceModeActive&&_voiceModeState==='listening'){
// No speech detected — restart listening
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },500);
}
};
_recognition.onerror=(event)=>{
clearTimeout(_silenceTimer);
if(event.error==='no-speech'||event.error==='aborted'){
// Restart if still active
if(_voiceModeActive){
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },800);
}
return;
}
if(event.error==='not-allowed'||event.error==='service-not-allowed'||event.error==='audio-capture'){
_deactivate();
showToast(t('mic_denied'));
return;
}
// Other errors — try to restart
if(_voiceModeActive){
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1500);
}
};
try{ _recognition.start(); }catch(e){
// Already started or other error — retry shortly
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1000);
}
}
function _voiceModeSend(){
if(!_voiceModeActive) return;
const text=(ta.value||'').trim();
if(!text){
ta.value='';
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },300);
return;
}
_setState('thinking');
// Pin the active session id so the TTS callback won't speak a different
// session's reply if the user navigates away mid-stream.
_voiceModeThinkingSid=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
try{ if(_recognition) _recognition.abort(); }catch(_){}
_recognition=null;
// send() is global from boot.js
if(typeof send==='function') send();
}
function _speakResponse(){
if(!_voiceModeActive) return;
// Bail out if the user navigated to a different session between send and
// stream completion. The patched autoReadLastAssistant fires globally;
// without this guard it would TTS-read the wrong session's last assistant
// message. Drop back to listening on the new session instead.
const currentSid=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
if(_voiceModeThinkingSid && currentSid && currentSid!==_voiceModeThinkingSid){
_voiceModeThinkingSid=null;
_startListening();
return;
}
_voiceModeThinkingSid=null;
_setState('speaking');
// Find last assistant message
const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
if(!rows.length){ _startListening(); return; }
const last=rows[rows.length-1];
const rawText=last.dataset.rawText||'';
if(!rawText.trim()){ _startListening(); return; }
// Strip for TTS (reuse existing helper if available)
let clean=rawText;
if(typeof _stripForTTS==='function') clean=_stripForTTS(rawText);
else{
// Basic strip: remove code blocks, images, links
clean=clean.replace(/```[\s\S]*?```/g,' code block ')
.replace(/`([^`]*)`/g,'$1')
.replace(/!\[([^\]]*)\]\([^)]*\)/g,'$1')
.replace(/\[([^\]]*)\]\([^)]*\)/g,'$1')
.replace(/#{1,6}\s/g,'')
.replace(/[*_~]+/g,'')
.replace(/\n{2,}/g,'. ')
.replace(/\n/g,' ')
.trim();
}
if(!clean){ _startListening(); return; }
const utter=new SpeechSynthesisUtterance(clean);
// Apply saved voice preferences
const savedVoice=localStorage.getItem('hermes-tts-voice');
const voices=speechSynthesis.getVoices();
if(savedVoice&&voices.length){
const match=voices.find(v=>v.name===savedVoice);
if(match) utter.voice=match;
}
const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
if(!isNaN(savedRate)) utter.rate=Math.min(2,Math.max(0.5,savedRate));
const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));
utter.onend=()=>{
// After speaking, go back to listening
if(_voiceModeActive) setTimeout(()=>_startListening(),500);
};
utter.onerror=()=>{
if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
};
speechSynthesis.speak(utter);
}
// Hook into response completion — observe when the agent finishes
// We patch setComposerStatus to detect when a response completes
const _origSetComposerStatus=(typeof setComposerStatus==='function')?setComposerStatus.bind(window):null;
window._voiceModeOnResponseComplete=function(){
if(_voiceModeActive&&_voiceModeState==='thinking'){
// Small delay to let DOM render the final message
setTimeout(()=>{
if(_voiceModeActive&&_voiceModeState==='thinking'){
_speakResponse();
}
},400);
}
};
// Observe S.busy changes to detect response completion
// The existing code calls setBusy(false) when response completes
const _origSetBusy=(typeof setBusy==='function')?setBusy.bind(window):null;
if(_origSetBusy){
// We use a MutationObserver-style approach via polling S.busy
// Actually, we'll use a simpler approach: hook into the message stream completion
}
// Most reliable hook: use the existing autoReadLastAssistant call site.
// We override autoReadLastAssistant so that if voice mode is active, we use our
// own speak-and-resume flow instead of the default auto-read.
const _origAutoRead=(typeof autoReadLastAssistant==='function')?autoReadLastAssistant:null;
window.autoReadLastAssistant=function(){
if(_voiceModeActive&&_voiceModeState==='thinking'){
_speakResponse();
return;
}
if(_origAutoRead) _origAutoRead.apply(this,arguments);
};
function _activate(){
_voiceModeActive=true;
modeBtn.classList.add('active');
_setButtonTooltip(modeBtn, t('voice_mode_toggle_active'));
showToast(t('voice_mode_active'),1500);
// If the agent is busy, wait — state will be 'thinking' and we'll detect completion
if(typeof S!=='undefined'&&S.busy){
_setState('thinking');
return;
}
// Cancel any existing TTS
if(typeof stopTTS==='function') stopTTS();
_startListening();
}
function _deactivate(){
_voiceModeActive=false;
_voiceModeState='idle';
_voiceModeThinkingSid=null;
modeBtn.classList.remove('active');
_setButtonTooltip(modeBtn, t('voice_mode_toggle'));
bar.style.display='none';
clearTimeout(_silenceTimer);
try{ if(_recognition) _recognition.abort(); }catch(_){}
_recognition=null;
if(typeof stopTTS==='function') stopTTS();
// Restore original autoReadLastAssistant
if(_origAutoRead) window.autoReadLastAssistant=_origAutoRead;
// Clear textarea if it was only voice input
ta.value='';
autoResize();
}
modeBtn.onclick=()=>{
if(_voiceModeActive){
_deactivate();
showToast(t('voice_mode_off'),1500);
}else{
_activate();
}
};
// Expose for external use
window._voiceModeActive=()=>_voiceModeActive;
window._voiceModeDeactivate=_deactivate;
window._voiceModeImmediateSend=_voiceModeSend;
})();
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
$('btnNewChat').onclick=async()=>{
// If the current session has no messages AND nothing is in flight, just focus
// the composer rather than creating another empty session that will clutter the
// sidebar list (#1171).
//
// The "nothing in flight" half is critical (#1432): if the user clicks + while
// their first message is still streaming (or queued), `message_count` is still 0
// server-side because the user turn hasn't been merged yet. The old guard treated
// that as "empty" and made + a no-op for the entire stream duration, so users
// couldn't actually start a parallel chat. Use the same in-flight signal as
// `_restoreSettledSession()` in messages.js: an active stream id or a queued
// pending user message means the session is real, not empty.
if(S.session
&& (S.session.message_count||0)===0
&& !S.busy
&& !S.session.active_stream_id
&& !S.session.pending_user_message){
$('msg').focus();closeMobileSidebar();return;
}
await newSession();await renderSessionList();closeMobileSidebar();$('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();
if(_currentPanel==='settings') switchPanel('chat');
showToast(t('session_imported'));
}
}catch(err){
showToast(t('import_failed')+(err.message||t('import_invalid_json')));
}
};
// btnRefreshFiles is now panel-icon-btn in header (see HTML)
function clearPreview(opts={}){
const keepPanelOpen=!!(opts&&opts.keepPanelOpen);
// Restore directory breadcrumb after closing file preview
if(typeof renderBreadcrumb==='function') renderBreadcrumb();
const closePanelAfter=_workspacePanelMode==='preview'&&!keepPanelOpen;
const pa=$('previewArea');if(pa)pa.classList.remove('visible');
const pi=$('previewImg');if(pi){pi.onerror=null;pi.src='';}
const pdf=$('previewPdfFrame');if(pdf)pdf.src='';
const html=$('previewHtmlIframe');if(html)html.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;
if(closePanelAfter)closeWorkspacePanel();
else if(keepPanelOpen&&_workspacePanelMode==='preview')openWorkspacePanel('browse');
else syncWorkspacePanelUI();
}
$('btnClearPreview').onclick=handleWorkspaceClose;
// workspacePath click handler removed -- use topbar workspace chip dropdown instead
$('modelSelect').onchange=async()=>{
if(!S.session)return;
const selectedModel=$('modelSelect').value;
const modelState=(typeof _modelStateForSelect==='function')
? _modelStateForSelect($('modelSelect'),selectedModel)
: {model:selectedModel,model_provider:null};
if(typeof closeModelDropdown==='function') closeModelDropdown();
if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider);
else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{}
await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id,
workspace:S.session.workspace,
model:modelState.model,
model_provider:modelState.model_provider||null,
})});
S.session.model=modelState.model;
S.session.model_provider=modelState.model_provider||null;
if(typeof syncModelChip==='function') syncModelChip();
syncTopbar();
// Clarify scope: composer model changes are session-local, not the global default.
if(typeof showToast==='function'){
showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
}
// Warn if selected model belongs to a different provider than what Hermes is configured for
if(typeof _checkProviderMismatch==='function'){
const warn=_checkProviderMismatch(selectedModel);
if(warn&&typeof showToast==='function') showToast(warn,4000);
}
};
$('msg').addEventListener('input',()=>{
autoResize();
updateSendBtn();
// Persist composer draft to server (debounced in _saveComposerDraft).
const sid = S && S.session && S.session.session_id;
if (sid && typeof _saveComposerDraft === 'function') {
_saveComposerDraft(sid, $('msg').value, S.pendingFiles ? [...S.pendingFiles] : []);
}
const text=$('msg').value;
if(text.startsWith('/')&&text.indexOf('\n')===-1){
if(typeof getSlashAutocompleteMatches==='function'){
getSlashAutocompleteMatches(text).then(matches=>{
if(($('msg').value||'')!==text) return;
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
});
}else{
const prefix=text.slice(1);
const matches=getMatchingCommands(prefix);
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
}
if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete();
} else {
hideCmdDropdown();
}
});
// Track IME composition for East Asian input. Safari fires the committing
// keydown AFTER compositionend with isComposing=false, so we also keep a
// manual flag and reset it on the next tick to swallow that trailing Enter.
// Also reset on blur so the flag can never get stuck in a true state if
// compositionend never fires (focus loss with some IME implementations).
//
// The `_imeComposing` flag is bound to the chat composer (`#msg`); other
// inputs (session/project rename, app dialog, message edit, workspace rename)
// rely on the state-free `e.isComposing || e.keyCode === 229` part of
// `_isImeEnter`, which is sufficient for the Safari race because keyCode 229
// is the canonical "still composing" signal regardless of which field is
// focused. Promote `_isImeEnter` to `window` so other modules can reuse it
// without duplicating the full IIFE per input (issue #1443).
let _imeComposing=false;
(()=>{const _c=$('msg');if(!_c)return;
_c.addEventListener('compositionstart',()=>{_imeComposing=true;});
_c.addEventListener('compositionend',()=>{setTimeout(()=>{_imeComposing=false;},0);});
_c.addEventListener('blur',()=>{_imeComposing=false;});
})();
function _isImeEnter(e){return e.isComposing||e.keyCode===229||_imeComposing;}
window._isImeEnter=_isImeEnter;
$('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){
if(_isImeEnter(e)){return;}
e.preventDefault();
selectCmdDropdownItem();
return;
}
}
// Send key: respect user preference.
// On touch-primary devices (software keyboard), default to Enter = newline
// since there's no physical Shift key. Users send via the Send button.
// The 'ctrl+enter' setting also uses this behavior (Enter = newline).
// Users can override in Settings by explicitly choosing 'enter' mode.
if(e.key==='Enter'){
if(_isImeEnter(e)){return;}
const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter';
if(window._sendKey==='ctrl+enter'||_mobileDefault){
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=>{
// 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){
const card=$('approvalCard');
const tag=(document.activeElement||{}).tagName||'';
if(card&&card.classList.contains('visible')&&tag!=='TEXTAREA'&&tag!=='INPUT'&&tag!=='SELECT'){
e.preventDefault();
if(typeof respondApproval==='function') respondApproval('once');
return;
}
}
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
e.preventDefault();
// If the current session has no messages AND nothing is in flight, just focus
// the composer rather than creating another empty session that will clutter
// the sidebar list (#1171). See the matching guard in $('btnNewChat').onclick
// and bug #1432 for why the in-flight check is needed.
if(S.session
&& (S.session.message_count||0)===0
&& !S.busy
&& !S.session.active_stream_id
&& !S.session.pending_user_message){
$('msg').focus();return;
}
// Cmd/Ctrl+K should always create a new conversation, even while the current
// one is still streaming. The old !S.busy guard meant users had to wait for
// a long generation to finish before they could start something new — exactly
// the moment they want to switch context. newSession() leaves the in-flight
// stream running on its own session; the user just gets a fresh blank one.
await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
}
if(e.key==='Escape'){
// Close onboarding overlay if open (skip/dismiss the wizard)
const onboardingOverlay=$('onboardingOverlay');
if(onboardingOverlay&&onboardingOverlay.style.display!=='none'){
if(typeof skipOnboarding==='function') skipOnboarding();
return;
}
// Close settings panel if active
if(_currentPanel==='settings'){_closeSettingsPanel();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||[]);
// When the clipboard carries BOTH text and an image (common from Notes,
// Word, browsers, Slack — the OS attaches a rendered preview alongside
// the plain text), prefer the text and let the browser paste normally.
// Only intercept when the clipboard is image-only (true screenshot paste).
// Tighten the image filter to kind==='file' so string items advertising an
// image MIME (e.g. text/html with an embedded data URI) are not misclassified.
const hasText=items.some(i=>i.kind==='string'&&(i.type==='text/plain'||i.type==='text/html'));
const imageItems=items.filter(i=>i.kind==='file'&&i.type.startsWith('image/'));
if(!imageItems.length||hasText)return;
e.preventDefault();
const pasteTs=Date.now();
const files=imageItems.map((i,idx)=>{
const blob=i.getAsFile();
const ext=i.type.split('/')[1]||'png';
const suffix=imageItems.length>1?`-${idx+1}`:'';
return new File([blob],`screenshot-${pasteTs}${suffix}.${ext}`,{type:i.type});
});
addFiles(files);
setStatus(t('image_pasted')+files.map(f=>f.name).join(', '));
});
document.querySelectorAll('.suggestion').forEach(btn=>{
btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();};
});
window.addEventListener('resize',()=>{
_syncWorkspacePanelInlineWidth();
syncWorkspacePanelState();
});
// Boot: restore last session or start fresh
// ── Resizable panels ──────────────────────────────────────────────────────
(function(){
const SIDEBAR_MIN=180, SIDEBAR_MAX=420;
const PANEL_MIN=180, PANEL_MAX=1200;
function initResize(handleId, targetEl, edge, minW, maxW, storageKey){
const handle = $(handleId);
if(!handle || !targetEl) return;
// Restore saved width
if(storageKey === 'hermes-panel-w'){
_syncWorkspacePanelInlineWidth();
}else{
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');
};
})();
// ── Appearance helpers (theme = light/dark/system, skin = accent color) ──────
const _THEMES=[
{name:'Light', value:'light', colors:['#FEFCF7','#FAF7F0','#B8860B']},
{name:'Dark', value:'dark', colors:['#0D0D1A','#141425','#FFD700']},
{name:'System', value:'system', colors:['#FEFCF7','#0D0D1A','#B8860B']},
];
const _SKINS=[
{name:'Default', colors:['#FFD700','#FFBF00','#CD7F32']},
{name:'Ares', colors:['#FF4444','#CC3333','#992222']},
{name:'Mono', colors:['#CCCCCC','#999999','#666666']},
{name:'Slate', colors:['#334155','#475569','#64748b']},
{name:'Poseidon', colors:['#0EA5E9','#0284C7','#0369A1']},
{name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
{name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
{name:'Sienna', colors:['#D97757','#C06A49','#9A523A']},
];
const _VALID_THEMES=new Set((_THEMES||[]).map(t=>t.value));
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase()));
const _LEGACY_THEME_MAP={
slate:{theme:'dark',skin:'slate'},
solarized:{theme:'dark',skin:'poseidon'},
monokai:{theme:'dark',skin:'sisyphus'},
nord:{theme:'dark',skin:'slate'},
oled:{theme:'dark',skin:'default'},
};
let _systemThemeMq=null;
let _onSystemThemeChange=null;
function _normalizeAppearance(theme,skin){
const rawTheme=typeof theme==='string'?theme.trim().toLowerCase():'';
const rawSkin=typeof skin==='string'?skin.trim().toLowerCase():'';
const legacy=_LEGACY_THEME_MAP[rawTheme];
const nextTheme=legacy?legacy.theme:(_VALID_THEMES.has(rawTheme)?rawTheme:'dark');
const nextSkin=_VALID_SKINS.has(rawSkin)?rawSkin:(legacy?legacy.skin:'default');
return {theme:nextTheme,skin:nextSkin};
}
// Sync <meta name="theme-color"> with the active theme's computed --bg.
// This surfaces the WebUI's exact theme background to:
// 1. Mobile Safari status bar (the prefers-color-scheme media variants in index.html
// cover the pre-load case; this updater handles user-toggled changes mid-session).
// 2. iOS PWA / Add to Home Screen status bar.
// 3. Native WKWebView wrappers (e.g. hermes-swift-mac) that read this attribute as
// the source of truth for AppKit chrome (tab bar, title bar, traffic-light area)
// instead of pixel-sampling — overlay-resistant and IPC-free.
// Reading getComputedStyle(html).getPropertyValue('--bg') picks up the active skin
// (Default, Sienna, Sisyphus, Charizard, etc.) so each skin's distinct paint reaches
// the meta tag.
function _syncThemeColorMeta(){
try{
const bg=getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
if(!bg) return;
const known=document.getElementById('hermes-theme-color');
if(known){
known.setAttribute('content',bg);
known.removeAttribute('media');
}
document.querySelectorAll('meta[name="theme-color"]').forEach(meta=>{
meta.setAttribute('content',bg);
meta.removeAttribute('media');
});
}catch(e){}
}
function _setResolvedTheme(isDark){
document.documentElement.classList.toggle('dark',!!isDark);
const link=document.getElementById('prism-theme');
if(!link){ _syncThemeColorMeta(); return; }
const want=isDark
?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
:'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
// No SRI integrity on theme CSS — jsdelivr edge nodes serve different
// digests for the same pinned version, causing intermittent blocking (#1100).
if(link.href!==want){ link.integrity=''; link.href=want; }
_syncThemeColorMeta();
}
function _applyTheme(name){
const normalized=_normalizeAppearance(name,'default');
delete document.documentElement.dataset.theme;
if(_systemThemeMq&&_onSystemThemeChange){
_systemThemeMq.removeEventListener('change',_onSystemThemeChange);
_systemThemeMq=null;
_onSystemThemeChange=null;
}
if(normalized.theme==='system'){
_systemThemeMq=window.matchMedia('(prefers-color-scheme:dark)');
_onSystemThemeChange=()=>_setResolvedTheme(_systemThemeMq.matches);
_setResolvedTheme(_systemThemeMq.matches);
_systemThemeMq.addEventListener('change',_onSystemThemeChange);
return;
}
_setResolvedTheme(normalized.theme==='dark');
}
function _applySkin(name){
const key=(name||'default').toLowerCase();
if(key==='default') delete document.documentElement.dataset.skin;
else document.documentElement.dataset.skin=key;
_syncThemeColorMeta();
}
function _pickTheme(name){
const currentSkin=localStorage.getItem('hermes-skin');
const appearance=_normalizeAppearance(name,currentSkin);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
_syncThemePicker(appearance.theme);
_syncSkinPicker(appearance.skin);
const hidden=$('settingsTheme');
if(hidden) hidden.value=appearance.theme;
const skinHidden=$('settingsSkin');
if(skinHidden) skinHidden.value=appearance.skin;
if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave();
}
function _pickSkin(name){
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),name);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
_syncThemePicker(appearance.theme);
_syncSkinPicker(appearance.skin);
const hidden=$('settingsSkin');
if(hidden) hidden.value=appearance.skin;
const themeHidden=$('settingsTheme');
if(themeHidden) themeHidden.value=appearance.theme;
if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave();
}
function _syncThemePicker(active){
document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{
btn.classList.toggle('active',btn.dataset.themeVal===active);
btn.style.borderColor='';
btn.style.boxShadow='';
});
}
function _syncSkinPicker(active){
document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{
btn.classList.toggle('active',btn.dataset.skinVal===active);
btn.style.borderColor='';
btn.style.boxShadow='';
});
}
function _applyFontSize(size){
if(size&&size!=='default'){
document.documentElement.dataset.fontSize=size;
} else {
delete document.documentElement.dataset.fontSize;
}
}
function _pickFontSize(size){
localStorage.setItem('hermes-font-size',size);
_applyFontSize(size);
_syncFontSizePicker(size);
const hidden=$('settingsFontSize');
if(hidden) hidden.value=size;
if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave();
}
function _syncFontSizePicker(active){
document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn').forEach(btn=>{
btn.classList.toggle('active',btn.dataset.fontSizeVal===(active||'default'));
btn.style.borderColor='';
btn.style.boxShadow='';
});
}
function _buildSkinPicker(activeSkin){
const grid=$('skinPickerGrid');
if(!grid) return;
grid.innerHTML='';
for(const skin of _SKINS){
const key=skin.name.toLowerCase();
const btn=document.createElement('button');
btn.type='button';
btn.className='skin-pick-btn';
btn.dataset.skinVal=key;
btn.style.cssText='border:1px solid var(--border2);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;background:none;transition:all .15s';
btn.onclick=()=>_pickSkin(skin.name);
const dots=skin.colors.map(c=>`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c}"></span>`).join('');
btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${skin.name}</span>`;
grid.appendChild(btn);
}
_syncSkinPicker((activeSkin||'default').toLowerCase());
}
function applyBotName(){
// Prefer profile name over global bot_name for personalised placeholder.
// If activeProfile is set and not 'default', use it (capitalised).
// Falls back to window._botName (global bot_name setting) or 'Hermes'.
let name;
if(S.activeProfile && S.activeProfile!=='default'){
name=S.activeProfile.charAt(0).toUpperCase()+S.activeProfile.slice(1);
}else{
name=window._botName||'Hermes';
}
document.title=name;
const sidebarH1=document.querySelector('.sidebar-header h1');
if(sidebarH1) sidebarH1.textContent=name;
const logo=document.querySelector('.sidebar-header .logo');
if(logo) logo.textContent=name.charAt(0).toUpperCase();
const topbarTitle=$('topbarTitle');
if(topbarTitle && (!S.session)) topbarTitle.textContent=name;
const msg=$('msg');
if(msg) msg.placeholder='Message '+name+'\u2026';
}
(async()=>{
// Load send key preference
let _bootSettings={};
try{
const s=await api('/api/settings');
_bootSettings=s;
window._sendKey=s.send_key||'enter';
window._showTokenUsage=!!s.show_token_usage;
window._showTps=!!s.show_tps;
window._showCliSessions=!!s.show_cli_sessions;
window._soundEnabled=!!s.sound_enabled;
window._notificationsEnabled=!!s.notifications_enabled;
// Persist default workspace so the blank new-chat page can show it
// and workspace actions (New file/folder) work before the first session (#804).
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
window._whatsNewSummaryEnabled=!!s.whats_new_summary_enabled;
window._showThinking=s.show_thinking!==false;
window._simplifiedToolCalling=s.simplified_tool_calling!==false;
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
window._busyInputMode=(s.busy_input_mode||'queue');
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
window._botName=s.bot_name||'Hermes';
if(s.default_model) window._defaultModel=s.default_model;
window._sessionJumpButtonsEnabled=!!s.session_jump_buttons;
const appearance=_normalizeAppearance(s.theme,s.skin);
localStorage.setItem('hermes-theme',appearance.theme);
_applyTheme(appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applySkin(appearance.skin);
const fontSize=(s.font_size||localStorage.getItem('hermes-font-size')||'default');
localStorage.setItem('hermes-font-size',fontSize);
_applyFontSize(fontSize);
if(typeof setLocale==='function'){
const _lang=typeof resolvePreferredLocale==='function'
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'))
: (s.language || localStorage.getItem('hermes-lang') || 'en');
setLocale(_lang);
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
}
applyBotName();
// TTS: apply enabled state on boot so buttons show/hide correctly (#499)
if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true');
}catch(e){
window._sendKey='enter';
window._showTokenUsage=false;
window._showTps=false;
window._showCliSessions=false;
window._soundEnabled=false;
window._notificationsEnabled=false;
window._whatsNewSummaryEnabled=false;
window._showThinking=true;
window._simplifiedToolCalling=true;
window._sessionJumpButtonsEnabled=false;
window._sidebarDensity='compact';
window._busyInputMode='queue';
window._sessionEndlessScrollEnabled=false;
window._botName='Hermes';
_bootSettings={check_for_updates:false};
if(typeof setLocale==='function'){
const _lang=typeof resolvePreferredLocale==='function'
? resolvePreferredLocale(null, localStorage.getItem('hermes-lang'))
: (localStorage.getItem('hermes-lang') || 'en');
setLocale(_lang);
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
}
applyBotName();
if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true');
}
// Non-blocking update check (fire-and-forget, once per tab session)
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':'');
api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
}
// Fetch active profile
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
// Update profile chip label immediately
const profileLabel=$('profileChipLabel');
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
// Fetch available models without blocking session restore. The static HTML
// options are enough for first paint; the dynamic provider list can settle
// after the saved session is visible.
const _modelDropdownReady=populateModelDropdown().then(()=>{
const savedState=(typeof _readPersistedModelState==='function')
? _readPersistedModelState()
: (localStorage.getItem('hermes-webui-model')?{model:localStorage.getItem('hermes-webui-model'),model_provider:null}:null);
const savedModel=savedState&&savedState.model;
if(savedModel && $('modelSelect')){
const applied=(typeof _applyModelToDropdown==='function')
? _applyModelToDropdown(savedModel,$('modelSelect'),savedState.model_provider||null)
: null;
if(!applied) $('modelSelect').value=savedModel;
// If the value didn't take (model not in list), clear the bad pref
if(!applied&&$('modelSelect').value!==savedModel){
if(typeof _clearPersistedModelState==='function') _clearPersistedModelState();
else localStorage.removeItem('hermes-webui-model');
}
else if(typeof syncModelChip==='function') syncModelChip();
}
if(S.session) syncTopbar();
}).catch(()=>{});
window._modelDropdownReady=_modelDropdownReady;
// Pre-load workspace list so sidebar name is correct from first render.
// Render the session list before restoring the saved conversation so a stale
// saved-session/client-side boot error cannot leave the sidebar empty forever.
await loadWorkspaceList();
await loadOnboardingWizard();
await renderSessionList();
_initResizePanels();
// Workspace panel restore happens AFTER loadSession so we know if
// the session has a workspace — prevents the snap-open-then-closed flash (#576).
// Fix #822: clear any browser-restored value before first render. This
// covers fresh page loads and reloads. The bfcache restore case is handled
// separately below by a `pageshow` listener — the async IIFE here does NOT
// re-run when the browser restores the page from bfcache.
const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = '';
// Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load)
if(typeof fetchReasoningChip==='function') fetchReasoningChip();
const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
const savedLocal=localStorage.getItem('hermes-webui-session');
const saved=urlSession||savedLocal;
if(saved){
try{
if(!urlSession&&savedLocal&&await _savedSessionShouldStaySidebarOnly(savedLocal)){
S.session=null; S.messages=[]; S.activeStreamId=null; S.busy=false;
S._bootReady=true;
syncTopbar();syncWorkspacePanelState();
$('emptyState').style.display='';
await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();
return;
}
await loadSession(saved);
// If the restored session has no messages it is an ephemeral scratch pad —
// treat the page as a fresh start rather than resuming a blank conversation.
// loadSession() already ran, so loadDir() has populated the workspace file tree.
// Do NOT remove the session ID from localStorage — keeping it means every
// subsequent refresh will also run loadSession() → loadDir() → files stay visible.
// Removing it here caused the file tree to go blank on the second refresh
// because the "no saved session" path never calls loadDir (#workspace-files).
const _restoredInFlight = S.session && (
S.session.active_stream_id ||
S.session.pending_user_message
);
if(S.session && (S.session.message_count||0) === 0 && !_restoredInFlight){
S.session=null; S.messages=[];
S._bootReady=true;
// Restore panel pref before syncing so the workspace panel stays visible
// even though there is no active session (#workspace-persist).
const _ephPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
if(_ephPanelPref) _workspacePanelMode='browse';
syncTopbar();syncWorkspacePanelState();
$('emptyState').style.display='';
await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();
return;
}
// Restore the panel from localStorage when the session has a workspace.
// Preference key takes priority over runtime state so that closing
// the panel via toolbar X doesn't suppress the "keep open" setting.
const panelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
if(S.session&&S.session.workspace&&panelPref){
_workspacePanelMode='browse';
}
S._bootReady=true;
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
catch(e){localStorage.removeItem('hermes-webui-session');}
}
// no saved session - show empty state, wait for user to hit +
S._bootReady=true;
syncTopbar();
// Restore panel pref so the workspace panel stays visible on a fresh load if the
// user had it open during their last session (#workspace-persist).
const _freshPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
if(_freshPanelPref) _workspacePanelMode='browse';
syncWorkspacePanelState();
$('emptyState').style.display='';
await renderSessionList();
// Start real-time gateway session sync if setting is enabled
if(typeof startGatewaySSE==='function') startGatewaySSE();
})().catch(e=>{
console.error('[hermes] boot failed', e);
try{S._bootReady=true;}catch(_){}
try{syncTopbar();}catch(_){}
try{syncWorkspacePanelState();}catch(_){}
try{$('emptyState').style.display='';}catch(_){}
try{if(typeof renderSessionList==='function') void renderSessionList();}catch(_){}
});
// Fix #822 (bfcache path): when the browser restores the page from the
// back-forward cache, the async boot IIFE above does NOT re-run, but the
// DOM — including any stale value in #sessionSearch — IS restored. A
// prior search string would silently hide all sessions via the filter in
// renderSessionListFromCache(). Clear the field and re-run the full layout
// sync whenever the page is restored from cache (`event.persisted === true`).
// Fix #1045: also re-run topbar/workspace/panel state so the rail and layout
// chrome aren't left in the stale bfcache snapshot.
window.addEventListener('pageshow', async (event) => {
if (!event.persisted) return; // fresh loads are handled by the IIFE above
const _srch = document.getElementById('sessionSearch');
if (_srch) _srch.value = '';
// Close any dropdowns/popovers that were open when the user navigated away.
// bfcache freezes DOM state, so a dropdown left open remains open on restore.
if (typeof closeModelDropdown === 'function') try { closeModelDropdown(); } catch (_) {}
if (typeof closeReasoningDropdown === 'function') try { closeReasoningDropdown(); } catch (_) {}
if (typeof closeWsDropdown === 'function') try { closeWsDropdown(); } catch (_) {}
if (typeof closeProfileDropdown === 'function') try { closeProfileDropdown(); } catch (_) {}
// BFCache restores the frozen DOM without rerunning boot. Refresh the active
// session through the normal load path so in-flight sessions with
// active_stream_id / pending_user_message can reattach like a reload restore.
if (S.session && S.session.session_id && typeof loadSession === 'function') {
try {
await loadSession(S.session.session_id);
if (S.session && S.session.session_id && typeof checkInflightOnBoot === 'function') {
try { await checkInflightOnBoot(S.session.session_id); } catch (_) {}
}
} catch (_) {}
}
// Re-synchronise layout chrome that the boot IIFE sets up but bfcache
// doesn't re-run. Each call is guarded so missing helpers degrade silently.
if (typeof syncTopbar === 'function') try { syncTopbar(); } catch (_) {}
if (typeof syncWorkspacePanelState === 'function') try { syncWorkspacePanelState(); } catch (_) {}
if (typeof renderSessionListFromCache === 'function') {
try { renderSessionListFromCache(); } catch (_) {}
}
// 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 (_) {}
}
});