Unsaved-changes guard: - _closeSettingsPanel() intercepts all three close paths (X button, overlay click, Escape key) and checks _settingsDirty before closing - If dirty: shows inline 'Unsaved changes' bar with Save & Close / Discard - Discard reverts the live theme preview to what it was when panel opened - _markSettingsDirty() wired to all inputs via addEventListener in loadSettingsPanel() - saveSettings() now resets dirty flag and hides the bar on successful save Theme improvements: - Add 'Slate' theme: warm charcoal (#2b2d30 bg), a softer/lighter dark option that sits between Dark and the full light themes - Rework 'Light' theme: replace pure white (#f5f5f7) with warm off-white (#f0ede8) -- warmer, lower contrast, less harsh on most displays - Update /theme command to include 'slate' in valid list - Add test_settings_set_theme_slate() to test_sprint26.py
339 lines
13 KiB
JavaScript
339 lines
13 KiB
JavaScript
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){
|
|
// Switch the panel content view
|
|
switchPanel(name);
|
|
// For non-chat panels (tasks, skills, memory, spaces), open the sidebar
|
|
// so the panel is visible. For 'chat', the content is in the main area —
|
|
// just close the sidebar so the chat view is unobstructed.
|
|
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');
|
|
}
|
|
}
|
|
// 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<event.results.length;i++){
|
|
const t=event.results[i][0].transcript;
|
|
if(event.results[i].isFinal){ final+=t; _finalText=final; }
|
|
else{ interim+=t; }
|
|
}
|
|
// Append to whatever was already in the textarea before mic started
|
|
ta.value=_prefix+(final||interim);
|
|
autoResize();
|
|
};
|
|
|
|
recognition.onend=()=>{
|
|
// 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.onerror=null;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'){_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||[]);
|
|
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';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
|
|
// 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 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();
|
|
})();
|
|
|