2026-03-31 03:40:19 +00:00
|
|
|
async function cancelStream(){
|
|
|
|
|
const streamId = S.activeStreamId;
|
|
|
|
|
if(!streamId) return;
|
|
|
|
|
try{
|
2026-03-31 14:35:45 +00:00
|
|
|
await fetch(new URL(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{credentials:'include'});
|
2026-03-31 03:40:19 +00:00
|
|
|
const btn=$('btnCancel');if(btn)btn.style.display='none';
|
|
|
|
|
setStatus('Cancelling…');
|
|
|
|
|
}catch(e){setStatus('Cancel failed: '+e.message);}
|
|
|
|
|
}
|
|
|
|
|
|
feat: Sprint 21 — mobile responsive layout + Docker support
Mobile responsive (Issue #21):
- Hamburger sidebar: slide-in overlay on mobile (<640px) with backdrop.
Tap hamburger in topbar to open, tap outside to close. Full session
list, project chips, all panel content accessible.
- Bottom navigation bar: 5-tab fixed bar (Chat, Tasks, Skills, Memory,
Spaces) replaces sidebar nav tabs on mobile. iOS-style layout.
Tapping a tab opens the sidebar overlay with that panel active.
- Right panel slide-over: Files button in topbar chips opens workspace
panel as a slide-over from the right on mobile/tablet.
- Touch targets: all interactive elements get min 44x44px touch areas.
Session items, approval buttons, composer buttons all sized for fingers.
- Composer positioned above bottom nav bar with proper spacing.
- Sidebar nav tabs and bottom section hidden on mobile (replaced by
bottom nav + topbar chips).
- Clicking a session auto-closes the sidebar overlay.
- Desktop layout completely unchanged — all mobile elements are
display:none by default, only shown inside @media(max-width:640px).
Docker (Issue #7):
- Dockerfile: python:3.12-slim, HERMES_WEBUI_HOST=0.0.0.0, port 8787.
- docker-compose.yml: named volume for state persistence, optional
~/.hermes mount for agent features, password env var documented.
- README: Docker quick start section with compose and manual commands.
Tests: 392 passed, 23 pre-existing failures, 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:09:36 +00:00
|
|
|
// ── 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){
|
2026-04-03 17:21:42 +00:00
|
|
|
// Switch the panel content view
|
feat: Sprint 21 — mobile responsive layout + Docker support
Mobile responsive (Issue #21):
- Hamburger sidebar: slide-in overlay on mobile (<640px) with backdrop.
Tap hamburger in topbar to open, tap outside to close. Full session
list, project chips, all panel content accessible.
- Bottom navigation bar: 5-tab fixed bar (Chat, Tasks, Skills, Memory,
Spaces) replaces sidebar nav tabs on mobile. iOS-style layout.
Tapping a tab opens the sidebar overlay with that panel active.
- Right panel slide-over: Files button in topbar chips opens workspace
panel as a slide-over from the right on mobile/tablet.
- Touch targets: all interactive elements get min 44x44px touch areas.
Session items, approval buttons, composer buttons all sized for fingers.
- Composer positioned above bottom nav bar with proper spacing.
- Sidebar nav tabs and bottom section hidden on mobile (replaced by
bottom nav + topbar chips).
- Clicking a session auto-closes the sidebar overlay.
- Desktop layout completely unchanged — all mobile elements are
display:none by default, only shown inside @media(max-width:640px).
Docker (Issue #7):
- Dockerfile: python:3.12-slim, HERMES_WEBUI_HOST=0.0.0.0, port 8787.
- docker-compose.yml: named volume for state persistence, optional
~/.hermes mount for agent features, password env var documented.
- README: Docker quick start section with compose and manual commands.
Tests: 392 passed, 23 pre-existing failures, 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:09:36 +00:00
|
|
|
switchPanel(name);
|
2026-04-03 17:21:42 +00:00
|
|
|
// 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');
|
|
|
|
|
}
|
|
|
|
|
}
|
feat: Sprint 21 — mobile responsive layout + Docker support
Mobile responsive (Issue #21):
- Hamburger sidebar: slide-in overlay on mobile (<640px) with backdrop.
Tap hamburger in topbar to open, tap outside to close. Full session
list, project chips, all panel content accessible.
- Bottom navigation bar: 5-tab fixed bar (Chat, Tasks, Skills, Memory,
Spaces) replaces sidebar nav tabs on mobile. iOS-style layout.
Tapping a tab opens the sidebar overlay with that panel active.
- Right panel slide-over: Files button in topbar chips opens workspace
panel as a slide-over from the right on mobile/tablet.
- Touch targets: all interactive elements get min 44x44px touch areas.
Session items, approval buttons, composer buttons all sized for fingers.
- Composer positioned above bottom nav bar with proper spacing.
- Sidebar nav tabs and bottom section hidden on mobile (replaced by
bottom nav + topbar chips).
- Clicking a session auto-closes the sidebar overlay.
- Desktop layout completely unchanged — all mobile elements are
display:none by default, only shown inside @media(max-width:640px).
Docker (Issue #7):
- Dockerfile: python:3.12-slim, HERMES_WEBUI_HOST=0.0.0.0, port 8787.
- docker-compose.yml: named volume for state persistence, optional
~/.hermes mount for agent features, password env var documented.
- README: Docker quick start section with compose and manual commands.
Tests: 392 passed, 23 pre-existing failures, 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:09:36 +00:00
|
|
|
// Update bottom nav active state
|
|
|
|
|
document.querySelectorAll('.mobile-nav-btn').forEach(btn=>{
|
|
|
|
|
btn.classList.toggle('active',btn.dataset.panel===name);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
$('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();};
|
2026-03-31 03:40:19 +00:00
|
|
|
$('btnAttach').onclick=()=>$('fileInput').click();
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
|
|
|
|
|
// ── 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='';
|
fix: mic appends to existing textarea text instead of replacing it
Previously, tapping the mic button would reset the textarea each time,
clobbering anything the user had already typed or previously dictated.
Fix:
- Capture _prefix = ta.value when recording starts (btn.onclick)
- onresult writes _prefix + (final || interim) so live interim text
appears after the existing content, not replacing it
- onend commits _prefix + _finalText with smart space insertion:
if the prefix doesn't end with a space or newline, a space is added
before the new transcript so words don't run together
- _prefix is reset to '' in _setRecording(false) so each new recording
session starts with a fresh snapshot
Behaviour now: tap mic, speak, tap again (or wait for auto-stop) ->
transcript is appended to whatever was in the textarea. Tap mic again
-> continues appending further. Text stays fully editable before send.
tests/test_sprint20.py: 6 new tests covering prefix capture, onresult
prepend, onend commit, reset, and smart spacing (52 total, 382 overall).
2026-04-03 14:13:29 +00:00
|
|
|
let _prefix='';
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
|
|
|
|
|
function _setRecording(on){
|
|
|
|
|
window._micActive=on;
|
|
|
|
|
btn.classList.toggle('recording',on);
|
|
|
|
|
status.style.display=on?'':'none';
|
fix: mic appends to existing textarea text instead of replacing it
Previously, tapping the mic button would reset the textarea each time,
clobbering anything the user had already typed or previously dictated.
Fix:
- Capture _prefix = ta.value when recording starts (btn.onclick)
- onresult writes _prefix + (final || interim) so live interim text
appears after the existing content, not replacing it
- onend commits _prefix + _finalText with smart space insertion:
if the prefix doesn't end with a space or newline, a space is added
before the new transcript so words don't run together
- _prefix is reset to '' in _setRecording(false) so each new recording
session starts with a fresh snapshot
Behaviour now: tap mic, speak, tap again (or wait for auto-stop) ->
transcript is appended to whatever was in the textarea. Tap mic again
-> continues appending further. Text stays fully editable before send.
tests/test_sprint20.py: 6 new tests covering prefix capture, onresult
prepend, onend commit, reset, and smart spacing (52 total, 382 overall).
2026-04-03 14:13:29 +00:00
|
|
|
if(!on){ _finalText=''; _prefix=''; }
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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; }
|
|
|
|
|
}
|
fix: mic appends to existing textarea text instead of replacing it
Previously, tapping the mic button would reset the textarea each time,
clobbering anything the user had already typed or previously dictated.
Fix:
- Capture _prefix = ta.value when recording starts (btn.onclick)
- onresult writes _prefix + (final || interim) so live interim text
appears after the existing content, not replacing it
- onend commits _prefix + _finalText with smart space insertion:
if the prefix doesn't end with a space or newline, a space is added
before the new transcript so words don't run together
- _prefix is reset to '' in _setRecording(false) so each new recording
session starts with a fresh snapshot
Behaviour now: tap mic, speak, tap again (or wait for auto-stop) ->
transcript is appended to whatever was in the textarea. Tap mic again
-> continues appending further. Text stays fully editable before send.
tests/test_sprint20.py: 6 new tests covering prefix capture, onresult
prepend, onend commit, reset, and smart spacing (52 total, 382 overall).
2026-04-03 14:13:29 +00:00
|
|
|
// Append to whatever was already in the textarea before mic started
|
|
|
|
|
ta.value=_prefix+(final||interim);
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
autoResize();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
recognition.onend=()=>{
|
fix: mic appends to existing textarea text instead of replacing it
Previously, tapping the mic button would reset the textarea each time,
clobbering anything the user had already typed or previously dictated.
Fix:
- Capture _prefix = ta.value when recording starts (btn.onclick)
- onresult writes _prefix + (final || interim) so live interim text
appears after the existing content, not replacing it
- onend commits _prefix + _finalText with smart space insertion:
if the prefix doesn't end with a space or newline, a space is added
before the new transcript so words don't run together
- _prefix is reset to '' in _setRecording(false) so each new recording
session starts with a fresh snapshot
Behaviour now: tap mic, speak, tap again (or wait for auto-stop) ->
transcript is appended to whatever was in the textarea. Tap mic again
-> continues appending further. Text stays fully editable before send.
tests/test_sprint20.py: 6 new tests covering prefix capture, onresult
prepend, onend commit, reset, and smart spacing (52 total, 382 overall).
2026-04-03 14:13:29 +00:00
|
|
|
// 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
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
_setRecording(false);
|
fix: mic appends to existing textarea text instead of replacing it
Previously, tapping the mic button would reset the textarea each time,
clobbering anything the user had already typed or previously dictated.
Fix:
- Capture _prefix = ta.value when recording starts (btn.onclick)
- onresult writes _prefix + (final || interim) so live interim text
appears after the existing content, not replacing it
- onend commits _prefix + _finalText with smart space insertion:
if the prefix doesn't end with a space or newline, a space is added
before the new transcript so words don't run together
- _prefix is reset to '' in _setRecording(false) so each new recording
session starts with a fresh snapshot
Behaviour now: tap mic, speak, tap again (or wait for auto-stop) ->
transcript is appended to whatever was in the textarea. Tap mic again
-> continues appending further. Text stays fully editable before send.
tests/test_sprint20.py: 6 new tests covering prefix capture, onresult
prepend, onend commit, reset, and smart spacing (52 total, 382 overall).
2026-04-03 14:13:29 +00:00
|
|
|
ta.value=committed;
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
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='';
|
fix: mic appends to existing textarea text instead of replacing it
Previously, tapping the mic button would reset the textarea each time,
clobbering anything the user had already typed or previously dictated.
Fix:
- Capture _prefix = ta.value when recording starts (btn.onclick)
- onresult writes _prefix + (final || interim) so live interim text
appears after the existing content, not replacing it
- onend commits _prefix + _finalText with smart space insertion:
if the prefix doesn't end with a space or newline, a space is added
before the new transcript so words don't run together
- _prefix is reset to '' in _setRecording(false) so each new recording
session starts with a fresh snapshot
Behaviour now: tap mic, speak, tap again (or wait for auto-stop) ->
transcript is appended to whatever was in the textarea. Tap mic again
-> continues appending further. Text stays fully editable before send.
tests/test_sprint20.py: 6 new tests covering prefix capture, onresult
prepend, onend commit, reset, and smart spacing (52 total, 382 overall).
2026-04-03 14:13:29 +00:00
|
|
|
// Snapshot existing textarea content so we append rather than replace
|
|
|
|
|
_prefix=ta.value;
|
feat: add voice input mic button via Web Speech API
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
2026-04-03 14:04:03 +00:00
|
|
|
recognition.start();
|
|
|
|
|
_setRecording(true);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
})();
|
|
|
|
|
window._micActive=window._micActive||false;
|
2026-03-31 03:40:19 +00:00
|
|
|
$('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();
|
|
|
|
|
};
|
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
|
|
|
$('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'));
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-03-31 03:40:19 +00:00
|
|
|
// btnRefreshFiles is now panel-icon-btn in header (see HTML)
|
2026-04-03 11:33:24 +00:00
|
|
|
function clearPreview(){
|
|
|
|
|
const pa=$('previewArea');if(pa)pa.classList.remove('visible');
|
2026-04-04 16:09:31 +00:00
|
|
|
const pi=$('previewImg');if(pi){pi.onerror=null;pi.src='';}
|
2026-04-03 11:33:24 +00:00
|
|
|
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;
|
2026-03-31 03:40:19 +00:00
|
|
|
// 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();
|
|
|
|
|
};
|
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
|
|
|
$('msg').addEventListener('input',()=>{
|
|
|
|
|
autoResize();
|
feat: polish send button — hidden until content, icon-circle, pop-in animation
- index.html: btnSend hidden by default (display:none), icon-only (upward
arrow SVG, no text label), title attribute for accessibility
- style.css: new send-btn design — 34px circle, blue fill (#7cb9ff),
subtle glow box-shadow, scale() hover/active for tactile feel,
.send-btn.visible with @keyframes send-pop-in (scale+opacity spring
using cubic-bezier(.34,1.56,.64,1) for a satisfying pop). Mobile
override updated to preserve circle dimensions.
- ui.js: updateSendBtn() — shows button with pop-in animation when
textarea has content OR files are attached and agent is not busy;
hides instantly when content is cleared. Hooked into setBusy() and
renderTray() so button state tracks all content sources correctly.
- boot.js: input event listener calls updateSendBtn() on every keystroke.
- messages.js: autoResize() calls updateSendBtn() so button disappears
immediately after send clears the textarea.
- tests/test_sprint21.py: 33 tests covering HTML structure, CSS design
(circle shape, colors, animations, keyframes), JS logic (updateSendBtn,
setBusy, renderTray, autoResize integration), and regressions
(363 total, all pass).
2026-04-03 14:10:59 +00:00
|
|
|
updateSendBtn();
|
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
|
|
|
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();}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-31 03:40:19 +00:00
|
|
|
// 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'){
|
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
|
|
|
// Close settings overlay if open
|
|
|
|
|
const settingsOverlay=$('settingsOverlay');
|
|
|
|
|
if(settingsOverlay&&settingsOverlay.style.display!=='none'){toggleSettings();return;}
|
2026-03-31 03:40:19 +00:00
|
|
|
// 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()=>{
|
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
|
|
|
// Load send key preference
|
2026-04-04 04:06:23 +00:00
|
|
|
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;}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
|
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
|
|
|
// 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';
|
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
|
|
|
// Fetch available models from server and populate dropdown dynamically
|
|
|
|
|
await populateModelDropdown();
|
2026-03-31 03:40:19 +00:00
|
|
|
// 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();
|
|
|
|
|
})();
|
|
|
|
|
|