Files
webui/static/boot.js
nesquena-hermes ede1a5fc50 feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs

* Replace remaining emoji UI glyphs with Lucide icons

* Redesign composer footer around model and context controls

Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.

Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/

* Remove obsolete activity bar

Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.

This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.

* Move workspace and model switching into composer footer

* Move profile switching into composer footer

* Refactor Hermes control center UI

* Redesign control center settings modal layout

Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Put session item actions in a dropdown

* Use Hermes mark in sidebar control button

* Reset control center section on close

* Drop session-item left border indicator

Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Increase session search input vertical padding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Normalise odd pixel values across UI

Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)

The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints

Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.

* Improve composer footer mobile responsiveness and UX

- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior

* Fix duplicate files button and broken workspace close on mobile

Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix model chip icon vertical alignment in composer footer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix workspace toggle button hidden on desktop by conflicting CSS class

Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix session actions dots button inaccessible on mobile sidebar

Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix composer footer manage links not opening sidebar panel

The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Widen icon-only composer chips breakpoint from 400px to 768px

Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix composer-left vertical scrollbar by setting overflow-y:hidden

When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve rebase conflicts and fix control center test assertions

- Resolved 4 conflicts during rebase onto master (workspace.js,
  boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
  cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update version badge in System tab to v0.49.4

* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge

---------

Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 11:55:40 -07:00

514 lines
20 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, 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);
if(typeof setComposerStatus==='function') setComposerStatus('');
else setStatus('');
}
// ── Mobile navigation ──────────────────────────────────────────────────────
let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview'
function _isCompactWorkspaceViewport(){
return window.matchMedia('(max-width: 900px)').matches;
}
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';
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){
_setWorkspacePanelMode('closed');
return;
}
_setWorkspacePanelMode(_workspacePanelMode==='preview'?'closed':_workspacePanelMode);
}
function openWorkspacePanel(mode='browse'){
if(mode==='browse'&&!S.session&&!_hasWorkspacePreviewVisible())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();
}
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();
const hasPreview=_hasWorkspacePreviewVisible();
if(toggleBtn){
toggleBtn.classList.toggle('active',isOpen);
toggleBtn.setAttribute('aria-pressed',isOpen?'true':'false');
toggleBtn.title=isOpen?'Hide workspace panel':'Show workspace panel';
toggleBtn.disabled=!canBrowse;
}
if(collapseBtn){
collapseBtn.title=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;
clearBtn.title=hasPreview?'Close preview':'Hide workspace panel';
}
}
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(){
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){
// 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();
const overlay=$('settingsOverlay');
if(overlay) overlay.style.display='none';
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 closePanelAfter=_workspacePanelMode==='preview';
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();
if(closePanelAfter)closeWorkspacePanel();
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;
if(typeof closeModelDropdown==='function') closeModelDropdown();
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;
if(typeof syncModelChip==='function') syncModelChip();
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();};
});
window.addEventListener('resize',()=>{
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
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);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 +
syncTopbar();
syncWorkspacePanelState();
$('emptyState').style.display='';
await renderSessionList();
// Start real-time gateway session sync if setting is enabled
if(typeof startGatewaySSE==='function') startGatewaySSE();
})();