cancelStream() now clears S.activeStreamId, calls setBusy(false),
setStatus(''), and hides the cancel button directly after the cancel
API request completes. Previously cleanup depended on the SSE 'cancel'
event, which never arrived if the connection was already closed —
leaving 'Cancelling...' status and busy spinner stuck indefinitely.
The SSE cancel handler in messages.js still fires when the connection
is alive and performs additional cleanup (adds 'Task cancelled.' message,
clears tool cards). All operations are idempotent.
9 new tests in tests/test_sprint36.py.
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
412 lines
17 KiB
JavaScript
412 lines
17 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'});
|
|
}catch(e){/* cancel request failed — cleanup below still runs */}
|
|
// Clear status unconditionally after the cancel request completes.
|
|
// The SSE cancel event may also fire and call setBusy(false)/setStatus(''),
|
|
// but if the connection is already closed it won't arrive — so we handle
|
|
// cleanup here as the guaranteed path.
|
|
const btn=$('btnCancel');if(btn)btn.style.display='none';
|
|
S.activeStreamId=null;
|
|
setBusy(false);
|
|
setStatus('');
|
|
}
|
|
|
|
// ── 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');
|
|
// only hide overlay if right panel is also closed
|
|
const panel=document.querySelector('.rightpanel');
|
|
if(!panel||!panel.classList.contains('mobile-open')){
|
|
if(overlay)overlay.classList.remove('visible');
|
|
}
|
|
}
|
|
function toggleMobileFiles(){
|
|
const panel=document.querySelector('.rightpanel');
|
|
const overlay=$('mobileOverlay');
|
|
if(!panel)return;
|
|
if(panel.classList.contains('mobile-open')){
|
|
panel.classList.remove('mobile-open');
|
|
// only hide overlay if left sidebar is also closed
|
|
const sidebar=document.querySelector('.sidebar');
|
|
if(!sidebar||!sidebar.classList.contains('mobile-open')){
|
|
if(overlay)overlay.classList.remove('visible');
|
|
}
|
|
} else {
|
|
panel.classList.add('mobile-open');
|
|
if(overlay)overlay.classList.add('visible');
|
|
}
|
|
}
|
|
function closeMobileFiles(){
|
|
const panel=document.querySelector('.rightpanel');
|
|
const overlay=$('mobileOverlay');
|
|
if(panel)panel.classList.remove('mobile-open');
|
|
// only hide overlay if left sidebar is also closed
|
|
const sidebar=document.querySelector('.sidebar');
|
|
if(!sidebar||!sidebar.classList.contains('mobile-open')){
|
|
if(overlay)overlay.classList.remove('visible');
|
|
}
|
|
}
|
|
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=(typeof _locale!=='undefined'&&_locale._speech)||'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':t('mic_denied'),
|
|
'no-speech':t('mic_no_speech'),
|
|
'network':t('mic_network'),
|
|
};
|
|
showToast(msgs[event.error]||t('mic_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(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(){
|
|
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;
|
|
// Restore directory breadcrumb after closing file preview
|
|
if(typeof renderBreadcrumb==='function') renderBreadcrumb();
|
|
}
|
|
$('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();
|
|
// 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();
|
|
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=>{
|
|
// 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(!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(t('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=1200;
|
|
|
|
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');
|
|
};
|
|
})();
|
|
|
|
function applyBotName(){
|
|
const 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._showCliSessions=!!s.show_cli_sessions;window._soundEnabled=!!s.sound_enabled;window._notificationsEnabled=!!s.notifications_enabled;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);if(s.language&&typeof setLocale==='function'){setLocale(s.language);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._soundEnabled=false;window._notificationsEnabled=false;window._botName='Hermes';_bootSettings={check_for_updates:false};}
|
|
// 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 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();
|
|
await loadOnboardingWizard();
|
|
_initResizePanels();
|
|
const saved=localStorage.getItem('hermes-webui-session');
|
|
if(saved){
|
|
try{await loadSession(saved);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 +
|
|
$('emptyState').style.display='';
|
|
await renderSessionList();
|
|
// Start real-time gateway session sync if setting is enabled
|
|
if(typeof startGatewaySSE==='function') startGatewaySSE();
|
|
})();
|
|
|