// Early boot initialization that must run before any other code.
// These run during script evaluation to handle server-stopped state
// and cross-tab shutdown broadcasts as early as possible.
(function(){
  // Clear stale stop-server flag on successful page load (server is reachable)
  try{localStorage.removeItem('hermes-webui-server-stopped');}catch(_){}
  // Listen for shutdown broadcast from other tabs
  try {
    var _stopChan = new BroadcastChannel('hermes-webui-shutdown');
    _stopChan.onmessage = function() { _showServerStopped(); };
  } catch(_) {}
})();

// cancelStream: stop the active chat stream.
// See docs/rfcs/webui-run-state-consistency-contract.md (Invariants #2, #4)
// for the owner-aware + terminal-settle rationale.
async function cancelStream(){
  const sid = S.session && S.session.session_id;
  const streamId = S.activeStreamId;
  if(!streamId) return;
  let respBody=null;
  try{
    const r=await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
    try{respBody=await r.json();}catch(_){}
  }catch(e){
    if(typeof console !== 'undefined' && console.warn){
      console.warn('cancelStream: /api/chat/cancel request failed', e);
    }
  }
  // Active-session cancel should not tear down the current SSE transport before
  // the backend emits its terminal event; do that only for stale owner paths
  // where the user moved on to a different stream before this request
  // completed.
  if(sid && S.activeStreamId !== streamId && typeof closeLiveStream==='function'){
    closeLiveStream(sid, streamId);
  }
  // Owner guard: if the backend accepted the active-session cancel, leave
  // the current SSE transport and owner state intact so the terminal
  // `cancel` event can clear INFLIGHT, render "Task cancelled", and refresh
  // the sidebar. Only clear locally when the backend says there is no active
  // stream left to settle.
  if(respBody && respBody.cancelled===false && S.activeStreamId===streamId){
    S.activeStreamId=null;
    setBusy(false);
    if(typeof setComposerStatus==='function') setComposerStatus('');
    else setStatus('');
    // /api/chat/cancel only exposes `cancelled:bool`, so we cannot
    // distinguish reasons — keep the toast generic and short.
    if(typeof showToast==='function') showToast('Stream is no longer active',2000);
  }
}

async function cancelSessionStream(session){
  const streamId = session&&session.active_stream_id;
  const sid = session&&session.session_id;
  if(!streamId||!sid) return;
  try{
    await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
  }catch(e){/* close local stream; keep UI state honest below */}
  if(typeof closeLiveStream==='function') closeLiveStream(sid, streamId);
  session.active_stream_id=null;
  delete INFLIGHT[sid];
  clearInflightState(sid);
  if(S.session&&S.session.session_id===sid){
    S.activeStreamId=null;
    if(S.session) S.session.active_stream_id=null;
    clearInflight();
    setBusy(false);
    if(typeof setComposerStatus==='function') setComposerStatus('');
    else setStatus('');
  }
  if(typeof _approvalSessionId!=='undefined' && _approvalSessionId===sid){
    stopApprovalPolling();
    hideApprovalCard(true);
  }
  if(typeof _clarifySessionId!=='undefined' && _clarifySessionId===sid){
    stopClarifyPolling();
    hideClarifyCard(true, 'cancelled');
  }
  if(typeof renderSessionList==='function') renderSessionList();
}

async function _savedSessionShouldStaySidebarOnly(sid){
  if(!sid) return false;
  try{
    const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
    const session = data&&data.session;
    return !!(session&&(session.active_stream_id||session.pending_user_message));
  }catch(e){
    return false;
  }
}

// ── Mobile navigation ──────────────────────────────────────────────────────
let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview'

function _isCompactWorkspaceViewport(){
  return window.matchMedia('(max-width: 900px)').matches;
}

function _syncWorkspacePanelInlineWidth(){
  const {panel}= _workspacePanelEls();
  if(!panel) return;

  const isCompact = _isCompactWorkspaceViewport();
  if(isCompact){
    if(panel.style.width) panel.style.removeProperty('width');
    return;
  }

  const saved = localStorage.getItem('hermes-panel-w');
  if(!saved) return;
  const parsed = parseInt(saved, 10);
  if(Number.isNaN(parsed) || parsed <= 0) return;
  panel.style.width = `${parsed}px`;
}

function _workspacePanelEls(){
  return {
    layout: document.querySelector('.layout'),
    panel: document.querySelector('.rightpanel'),
    toggleBtn: $('btnWorkspacePanelToggle'),
    edgeToggleBtn: $('btnWorkspacePanelEdgeToggle'),
    collapseBtn: $('btnCollapseWorkspacePanel'),
  };
}

function _hasWorkspacePreviewVisible(){
  const preview=$('previewArea');
  return !!(preview&&preview.classList.contains('visible'));
}

function _setWorkspacePanelMode(mode){
  const {layout,panel}= _workspacePanelEls();
  if(!layout||!panel)return;
  _workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed';
  const open=_workspacePanelMode!=='closed';
  document.documentElement.dataset.workspacePanel=open?'open':'closed';
  // Persist open/closed across refreshes (browse/preview → open; closed → closed)
  // Do NOT overwrite the user's "keep open" preference — only track runtime state
  // so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting.
  try{localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');}catch(_){}
  layout.classList.toggle('workspace-panel-collapsed',!open);
  if(_isCompactWorkspaceViewport()){
    panel.classList.toggle('mobile-open',open);
  }else{
    panel.classList.remove('mobile-open');
  }
  syncWorkspacePanelUI();
}

function syncWorkspacePanelState(){
  const hasPreview=_hasWorkspacePreviewVisible();
  if(hasPreview){
    if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview');
    else syncWorkspacePanelUI();
    return;
  }
  if(!S.session){
    // No active session — if the panel was explicitly opened (browse mode), keep it
    // open so the workspace pane doesn't vanish on a fresh-page or empty-session boot.
    // The file tree will show the "no workspace" placeholder naturally via renderFileTree().
    // Only force-close if the mode is 'preview' (file preview without a session is invalid).
    if(_workspacePanelMode==='preview') _setWorkspacePanelMode('closed');
    else syncWorkspacePanelUI();
    return;
  }
  _setWorkspacePanelMode(_workspacePanelMode==='preview'?'closed':_workspacePanelMode);
}

function openWorkspacePanel(mode='browse'){
  if(mode==='browse'&&!S.session&&!_hasWorkspacePreviewVisible()&&!S._profileDefaultWorkspace)return;
  if(mode==='preview'&&_workspacePanelMode==='browse'){
    syncWorkspacePanelUI();
    return;
  }
  _setWorkspacePanelMode(mode);
}

function closeWorkspacePanel(){
  _setWorkspacePanelMode('closed');
}

function ensureWorkspacePreviewVisible(){
  if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview');
  else syncWorkspacePanelUI();
}

function handleWorkspaceClose(){
  if(_hasWorkspacePreviewVisible()){
    clearPreview();
    return;
  }
  closeWorkspacePanel();
}

/**
 * Set a tooltip on a button, preferring the custom CSS tooltip (`data-tooltip`)
 * when the element opts in via the `has-tooltip` class. Falls back to the
 * native `title` attribute for elements that haven't opted in.
 *
 * Critical: when the element DOES have data-tooltip, this MUST also clear any
 * existing native `title` attribute, otherwise the slow ~1.5s native browser
 * tooltip co-fires alongside the fast custom CSS tooltip — exactly the bug
 * #1775 reports. Always pair `data-tooltip` with `removeAttribute('title')`.
 */
function _setButtonTooltip(btn, text){
  if(!btn) return;
  if(btn.hasAttribute('data-tooltip')){
    btn.setAttribute('data-tooltip', text);
    if(btn.hasAttribute('title')) btn.removeAttribute('title');
  } else {
    btn.title = text;
  }
}

function syncWorkspacePanelUI(){
  const {layout,panel,toggleBtn,edgeToggleBtn,collapseBtn}= _workspacePanelEls();
  if(!layout||!panel)return;
  const desktopOpen=_workspacePanelMode!=='closed';
  const mobileOpen=panel.classList.contains('mobile-open');
  const isCompact=_isCompactWorkspaceViewport();
  const isOpen=isCompact?mobileOpen:desktopOpen;
  const canBrowse=!!S.session||_hasWorkspacePreviewVisible()||!!(S._profileDefaultWorkspace);
  const hasPreview=_hasWorkspacePreviewVisible();
  if(toggleBtn){
    toggleBtn.classList.toggle('active',isOpen);
    toggleBtn.setAttribute('aria-pressed',isOpen?'true':'false');
    _setButtonTooltip(toggleBtn, isOpen?'Hide workspace panel':'Show workspace panel');
    toggleBtn.disabled=!canBrowse;
  }
  if(edgeToggleBtn){
    edgeToggleBtn.classList.toggle('active',isOpen);
    edgeToggleBtn.setAttribute('aria-expanded',isOpen?'true':'false');
    _setButtonTooltip(edgeToggleBtn, isOpen?'Hide workspace panel':'Show workspace panel');
    edgeToggleBtn.disabled=!canBrowse;
  }
  if(collapseBtn){
    _setButtonTooltip(collapseBtn, isCompact?'Close workspace panel':'Hide workspace panel');
  }
  const hasSession=!!S.session;
  ['btnUpDir','btnNewFile','btnNewFolder','btnRefreshPanel'].forEach(id=>{
    const el=$(id);
    if(el)el.disabled=!hasSession;
  });
  const clearBtn=$('btnClearPreview');
  if(clearBtn){
    clearBtn.disabled=!isOpen;
    _setButtonTooltip(clearBtn, hasPreview?'Close preview':'Close');
    if(!isCompact) clearBtn.style.display='';
  }
}

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');
}

const _PWA_SIDEBAR_SWIPE_EDGE=28;
const _PWA_SIDEBAR_SWIPE_TRIGGER=72;
const _PWA_SIDEBAR_SWIPE_MAX_VERTICAL=48;
let _pwaSidebarSwipe=null;

function _isPwaStandalone(){
  try{
    return document.documentElement.classList.contains('pwa-standalone')
      || window.matchMedia('(display-mode: standalone)').matches
      || window.navigator.standalone===true;
  }catch(_){return false;}
}

function _isInteractiveSwipeTarget(target){
  try{return !!(target&&target.closest&&target.closest('input,textarea,select,button,a,[contenteditable="true"],.topbar-chips,.composer-left,.sidebar,.rightpanel'));}
  catch(_){return false;}
}

function _openMobileSidebarFromGesture(){
  if(_isDesktopWidth())return;
  const sidebar=document.querySelector('.sidebar');
  const overlay=$('mobileOverlay');
  if(!sidebar)return;
  const layout=document.querySelector('.layout');
  if(layout)layout.classList.remove('sidebar-collapsed');
  sidebar.classList.remove('sidebar-collapsed');
  try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){}
  sidebar.classList.add('mobile-open');
  if(overlay)overlay.classList.add('visible');
}

function _onPwaSidebarSwipeStart(e){
  if(!_isPwaStandalone()||_isDesktopWidth())return;
  if(e.pointerType==='mouse'||(e.pointerType&&e.pointerType!=='touch'&&e.pointerType!=='pen'))return;
  if(document.querySelector('.sidebar')?.classList.contains('mobile-open'))return;
  const clientX=Number(e.clientX)||0;
  if(clientX>_PWA_SIDEBAR_SWIPE_EDGE)return;
  if(_isInteractiveSwipeTarget(e.target))return;
  _pwaSidebarSwipe={startX:clientX,startY:Number(e.clientY)||0,active:true,opened:false};
}

function _onPwaSidebarSwipeMove(e){
  const swipe=_pwaSidebarSwipe;
  if(!swipe||!swipe.active||swipe.opened)return;
  const dx=(Number(e.clientX)||0)-swipe.startX;
  const dy=(Number(e.clientY)||0)-swipe.startY;
  if(dx<0||Math.abs(dy)>_PWA_SIDEBAR_SWIPE_MAX_VERTICAL*1.5){_pwaSidebarSwipe=null;return;}
  if(dx>=_PWA_SIDEBAR_SWIPE_TRIGGER&&Math.abs(dy)<=_PWA_SIDEBAR_SWIPE_MAX_VERTICAL&&dx>Math.abs(dy)*1.5){
    if(e.cancelable)e.preventDefault();
    swipe.opened=true;
    _openMobileSidebarFromGesture();
  }
}

function _onPwaSidebarSwipeEnd(){_pwaSidebarSwipe=null;}
function _onPwaSidebarSwipeCancel(){_pwaSidebarSwipe=null;}

function _installPwaSidebarSwipeGesture(){
  window.addEventListener('pointerdown', _onPwaSidebarSwipeStart, {passive:true});
  window.addEventListener('pointermove', _onPwaSidebarSwipeMove, {passive:false});
  window.addEventListener('pointerup', _onPwaSidebarSwipeEnd, {passive:true});
  window.addEventListener('pointercancel', _onPwaSidebarSwipeCancel, {passive:true});
}
_installPwaSidebarSwipeGesture();

// ── Desktop sidebar collapse toggle ────────────────────────────────────────
// Two discoverability paths into the same state:
//   (1) Click the already-active rail icon → collapse / expand the sidebar.
//   (2) Cmd/Ctrl+B keyboard shortcut (VS Code convention).
// Mobile is unaffected: the sidebar is an overlay there, and every collapse
// code path is gated on `_isDesktopWidth()` (min-width:641px).
// State is persisted via localStorage and survives reloads + bfcache.
const _SIDEBAR_COLLAPSED_KEY='hermes-webui-sidebar-collapsed';

function _isDesktopWidth(){
  try{return window.matchMedia('(min-width:641px)').matches;}catch(_){return true;}
}

function _isSidebarCollapsed(){
  return document.querySelector('.layout')?.classList.contains('sidebar-collapsed')||false;
}

function _syncSidebarAria(){
  // Mirror the open/collapsed state on the active rail button via aria-expanded
  // so screen readers announce the toggle. Open=true, collapsed=false.
  const active=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]');
  if(active)active.setAttribute('aria-expanded',!_isSidebarCollapsed());
}

function toggleSidebar(forceState){
  if(!_isDesktopWidth())return; // mobile uses an overlay; never collapse there
  const layout=document.querySelector('.layout');
  if(!layout)return;
  const next=typeof forceState==='boolean'?forceState:!_isSidebarCollapsed();
  layout.classList.toggle('sidebar-collapsed',next);
  // Clear the flash-prevention root-level marker once JS owns the state.
  try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){}
  try{localStorage.setItem(_SIDEBAR_COLLAPSED_KEY,next?'1':'0');}catch(_){}
  _syncSidebarAria();
}

function expandSidebar(){
  if(_isSidebarCollapsed())toggleSidebar(false);
}

// Boot-time restore. The inline flash-prevention script in index.html already
// set data-sidebar-collapsed='1' on <html> before the stylesheet so the page
// renders collapsed without paint flash. This IIFE promotes that pre-paint
// state into the .layout class system where both JS and CSS can read it.
(function _restoreSidebarState(){
  try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){}
  if(!_isDesktopWidth())return;
  try{
    if(localStorage.getItem(_SIDEBAR_COLLAPSED_KEY)==='1'){
      const layout=document.querySelector('.layout');
      if(layout)layout.classList.add('sidebar-collapsed');
    }
  }catch(_){}
  _syncSidebarAria();
})();
// ── Boot-time tab visibility ────────────────────────────────────────────────
// Apply hidden tabs from localStorage. The primary flash-prevention is an
// inline <script> in index.html (after sidebar-nav) that runs synchronously
// before first paint. This IIFE is a secondary fallback: it ensures consistency
// after panels.js is loaded and handles the active-tab switch. No-op if
// panels.js hasn't loaded yet (typeof guard).
(function _restoreTabVisibility(){
  try{
    if(typeof _applyTabOrder==='function'&&typeof _getTabOrder==='function'){
      _applyTabOrder(_getTabOrder());
    }
    if(typeof _applyTabVisibility==='function'&&typeof _getHiddenTabs==='function'){
      _applyTabVisibility(_getHiddenTabs());
    }
    var active=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]')
               ||document.querySelector('.sidebar-nav .nav-tab.active[data-panel]');
    if(active&&active.classList.contains('nav-tab-hidden')){
      var chatBtn=document.querySelector('.rail .rail-btn.nav-tab[data-panel="chat"]');
      if(chatBtn)chatBtn.classList.add('active');
      if(active)active.classList.remove('active');
    }
  }catch(_){}
})();
function toggleMobileFiles(){
  toggleWorkspacePanel();
}
function closeMobileWorkspacePanelFromChat(e){
  if(!_isCompactWorkspaceViewport()||_workspacePanelMode==='closed') return;
  const panel=document.querySelector('.rightpanel');
  if(panel&&panel.contains(e.target)) return;
  closeWorkspacePanel();
}
function toggleWorkspacePanel(force){
  const {panel}= _workspacePanelEls();
  if(!panel)return;
  const currentlyOpen=_workspacePanelMode!=='closed';
  const nextOpen=typeof force==='boolean'?force:!currentlyOpen;
  if(!nextOpen){
    closeWorkspacePanel();
    return;
  }
  const nextMode=_hasWorkspacePreviewVisible()?'preview':'browse';
  openWorkspacePanel(nextMode);
}
function mobileSwitchPanel(name){
  switchPanel(name);
  if(name==='chat'){
    closeMobileSidebar();
  } else {
    const sidebar=document.querySelector('.sidebar');
    const overlay=$('mobileOverlay');
    if(sidebar){
      sidebar.classList.add('mobile-open');
      if(overlay)overlay.classList.add('visible');
    }
  }
}

$('btnSend').onclick=()=>{
  if(typeof handleComposerPrimaryAction==='function') return handleComposerPrimaryAction();
  if(window._micActive){
    window._micPendingSend=true;
    _stopMic();
    return;
  }
  // Turn-based voice mode: let the voice mode system handle the send flow
  if(typeof window._voiceModeActive==='function'&&window._voiceModeActive()){
    // Immediately send whatever is in the textarea
    if(typeof window._voiceModeImmediateSend==='function') window._voiceModeImmediateSend();
    return;
  }
  send();
};
$('mainChat')?.addEventListener('pointerdown', closeMobileWorkspacePanelFromChat);
$('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInput').value='';$('fileInput').click();};

// ── Voice input (Web Speech API + MediaRecorder fallback) ───────────────────
(function(){
  const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
  const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder);
  if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden

  // Persist SR failure across reloads (e.g. Tailscale/network error)
  const _micForceMediaRecorderKey='mic_force_mediarecorder';
  const _micForceMediaRecorderStored=localStorage.getItem(_micForceMediaRecorderKey);
  // Prefer Hermes server-side STT (MediaRecorder -> /api/transcribe) only
  // after the server confirms an STT provider is available. No stored key must
  // keep browser SpeechRecognition as the first-click default until then; that
  // avoids dropping the first dictation on installs without server STT.
  let _serverSttAvailable=false;
  let _forceMediaRecorder=!SpeechRecognition||(_micForceMediaRecorderStored===null?(_serverSttAvailable&&_canRecordAudio):_micForceMediaRecorderStored==='1');

  // Raw audio mode preference: send audio file instead of transcribing
  let _rawAudioMode = localStorage.getItem('hermes-raw-audio-mode') === 'true';
  // Capture backend pinned at recording start ('speech' | 'media' | null) so
  // _stopMic / onstop act on the backend that actually started, even if the
  // raw-audio toggle changes mid-recording (#3169 Codex review).
  let _activeCaptureMode = null;

  const btn=$('btnMic');
  const status=$('micStatus');
  const ta=$('msg');
  const statusText=status?status.querySelector('.status-text'):null;
  btn.style.display=''; // Show button — browser supports speech recognition or recording fallback

  let recognition=null;
  let mediaRecorder=null;
  let mediaStream=null;
  let audioChunks=[];
  let _finalText='';
  let _prefix='';
  let _isRecording=false;

  function _setButtonTooltipAndKey(btn, key){
    const text = t(key);
    btn.setAttribute('data-i18n-title', key);
    if(btn.hasAttribute('data-tooltip')){
      btn.setAttribute('data-tooltip', text);
      if(btn.hasAttribute('title')) btn.removeAttribute('title');
    } else {
      btn.title = text;
    }
  }

  function _setRecording(on){
    window._micActive=on;
    btn.classList.toggle('recording',on);
    // Active-state title flips so the tooltip is honest about what
    // pressing the button will do (#1488).
    _setButtonTooltipAndKey(btn, on ? (_rawAudioMode ? 'voice_recording_active' : 'voice_dictate_active') : (_rawAudioMode ? 'voice_send_raw' : 'voice_dictate'));
    status.style.display=on?'':'none';
    if(statusText) statusText.textContent=on?'Listening':'Listening';
    if(!on){ _finalText=''; _prefix=''; }
  }

  function _updateMicTooltip(){
    if(!window._micActive){
      _setButtonTooltipAndKey(btn, _rawAudioMode ? 'voice_send_raw' : 'voice_dictate');
    }
  }

  async function _sendRawAudio(blob){
    const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
    const file=new File([blob],`voice-input-${Date.now()}.${ext}`,{type:blob.type||`audio/${ext}`});
    S.pendingFiles.push(file);
    renderTray();
    // An explicit Send-button click while recording sets _micPendingSend — that
    // is an unambiguous send intent, so honor it even when the composer already
    // has text (mirrors the transcribe path). Otherwise (manual mic-stop): send
    // immediately only if the composer is empty, else just attach + toast so the
    // user can keep composing.
    if(window._micPendingSend){
      window._micPendingSend=false;
      send();
    }else if(!ta.value.trim()){
      send();
    }else{
      showToast(t('voice_raw_attached'));
    }
  }

  function _commitTranscript(text){
    const clean=(text||'').trim();
    const committed=clean
      ? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
          ? _prefix+' '+clean.trimStart()
          : _prefix+clean)
      : ta.value;
    ta.value=committed;
    autoResize();
    if(window._micPendingSend){
      window._micPendingSend=false;
      send();
    }
  }

  function _isServerSttUnavailable(err){
    const status=err&&err.status;
    if(status===404||status===503||status>=500) return true;
    if(!status) return true;
    const msg=String((err&&err.message)||'').toLowerCase();
    return msg.includes('unavailable')||msg.includes('not configured');
  }

  function _allowBrowserSttFallback(){
    return !!(SpeechRecognition&&localStorage.getItem(_micForceMediaRecorderKey)!=='1');
  }

  async function _transcribeBlob(blob){
    const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
    const form=new FormData();
    form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`}));
    setComposerStatus('Transcribing…');
    try{
      const res=await fetch('api/transcribe',{method:'POST',body:form});
      const data=await res.json().catch(()=>({}));
      if(!res.ok){
        const err=new Error(data.error||'Transcription failed');
        err.status=res.status;
        throw err;
      }
      _commitTranscript(data.transcript||'');
    }catch(err){
      if(_isServerSttUnavailable(err)&&_allowBrowserSttFallback()){
        window._micPendingSend=false;
        localStorage.setItem(_micForceMediaRecorderKey,'0');
        _forceMediaRecorder=false;
        recognition=_ensureSpeechRecognition();
        showToast(err.message||t('mic_network'));
        return;
      }
      window._micPendingSend=false;
      showToast(err.message||t('mic_network'));
    }finally{
      setComposerStatus('');
    }
  }

  function _stopTracks(){
    if(mediaStream){
      mediaStream.getTracks().forEach(track=>track.stop());
      mediaStream=null;
    }
  }

  function _stopMic(){
    if(!window._micActive) return;
    // Stop the backend that was ACTIVE WHEN RECORDING STARTED — not whatever
    // _rawAudioMode says now. The user can toggle Settings → Sound mid-recording,
    // which would otherwise make us stop the wrong backend and orphan the other
    // (#3169 Codex review). _activeCaptureMode is pinned at start.
    if(recognition && _activeCaptureMode==='speech'){
      recognition.stop();
      return;
    }
    if(mediaRecorder&&mediaRecorder.state!=='inactive'){
      mediaRecorder.stop();
      return;
    }
    _setRecording(false);
    _stopTracks();
  }
  window._stopMic=_stopMic; // expose for send-guard above

  function _ensureSpeechRecognition(){
    if(!SpeechRecognition) return null;
    const sr=recognition||new SpeechRecognition();
    sr.continuous=false;
    sr.interimResults=true;
    sr.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';

    sr.onstart=()=>{ _finalText=''; };

    sr.onresult=(event)=>{
      let interim='';
      let final=_finalText;
      for(let i=event.resultIndex;i<event.results.length;i++){
        const t=event.results[i][0].transcript;
        if(event.results[i].isFinal){ final+=t; _finalText=final; }
        else{ interim+=t; }
      }
      ta.value=_prefix+(final||interim);
      autoResize();
    };

    sr.onend=()=>{
      const committed=_finalText
        ? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
            ? _prefix+' '+_finalText.trimStart()
            : _prefix+_finalText)
        : ta.value;
      _setRecording(false);
      ta.value=committed;
      autoResize();
      if(window._micPendingSend){
        window._micPendingSend=false;
        send();
      }
      _applyDeferredServerSttFlip();
    };

    sr.onerror=(event)=>{
      _setRecording(false);
      window._micPendingSend=false;
      _isRecording=false;
      if(event.error==='network'||event.error==='not-allowed'){
        // Persist SR failure: next reload will skip SpeechRecognition
        localStorage.setItem(_micForceMediaRecorderKey,'1');
        _forceMediaRecorder=true;
        recognition=null;
      }
      const msgs={
        'not-allowed':t('mic_denied'),
        'no-speech':t('mic_no_speech'),
        'network':t('mic_network'),
      };
      showToast(msgs[event.error]||t('mic_error')+event.error);
    };

    return sr;
  }

  if(!_forceMediaRecorder){
    recognition=_ensureSpeechRecognition();
  }

  async function _probeServerSttCapability(){
    if(!_canRecordAudio||_micForceMediaRecorderStored!==null) return;
    try{
      const res=await fetch('api/transcribe/capability',{cache:'no-store'});
      const data=await res.json().catch(()=>({}));
      if(res.ok&&data&&data.available){
        _serverSttAvailable=true;
        if(!window._micActive){
          _forceMediaRecorder=true;
          recognition=null;
        }
      }
    }catch(_err){
      // Keep browser SpeechRecognition as the safe first-click default when the
      // passive capability probe fails.
    }
  }

  // If the capability probe resolved WHILE a session was active, the flip to
  // server STT was deferred to protect that in-flight session. Apply it once the
  // session ends so subsequent clicks use the configured server STT as intended.
  // Reads LIVE localStorage (not the init-time const) so a fallback that just
  // persisted '0' is respected and not re-flipped.
  function _applyDeferredServerSttFlip(){
    if(_serverSttAvailable&&!_forceMediaRecorder&&!window._micActive
        &&localStorage.getItem(_micForceMediaRecorderKey)===null){
      _forceMediaRecorder=true;
      recognition=null;
    }
  }

  _probeServerSttCapability();

  btn.onclick=async()=>{
    // Race-condition guard: ignore rapid double-clicks
    if(_isRecording){
      _stopMic();
      _isRecording=false;
      return;
    }
    if(window._micActive){
      _stopMic();
      return;
    }
    _isRecording=true;
    _finalText='';
    _prefix=ta.value;
    if(recognition && !_forceMediaRecorder && !_rawAudioMode){
      _activeCaptureMode='speech';
      recognition.start();
      _setRecording(true);
      return;
    }
    if(!_canRecordAudio){
      _isRecording=false;
      showToast(t('mic_network'));
      return;
    }
    try{
      mediaStream=await navigator.mediaDevices.getUserMedia({audio:true});
      const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg'];
      const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||'';
      mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined);
      audioChunks=[];
      mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);};
      mediaRecorder.onerror=()=>{
        _isRecording=false;
        _setRecording(false);
        window._micPendingSend=false;
        _stopTracks();
        showToast(t('mic_network'));
      };
      mediaRecorder.onstop=async()=>{
        _isRecording=false;
        const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'});
        _setRecording(false);
        _stopTracks();
        if(blob.size){
          if(_activeCaptureMode==='media-raw'){
            await _sendRawAudio(blob);
          }else{
            await _transcribeBlob(blob);
          }
        }
        else if(window._micPendingSend){
          window._micPendingSend=false;
        }
        _applyDeferredServerSttFlip();
      };
      _activeCaptureMode=_rawAudioMode?'media-raw':'media-transcribe';
      mediaRecorder.start();
      _setRecording(true);
    }catch(err){
      _isRecording=false;
      window._micPendingSend=false;
      _stopTracks();
      showToast(t('mic_denied'));
    }
  };

  // Wire up the settings checkbox
  const rawAudioCheckbox = document.getElementById('settingsRawAudio');
  if(rawAudioCheckbox){
    rawAudioCheckbox.checked = _rawAudioMode;
    rawAudioCheckbox.addEventListener('change', function(){
      _rawAudioMode = this.checked;
      localStorage.setItem('hermes-raw-audio-mode', _rawAudioMode ? 'true' : 'false');
      _updateMicTooltip();
    });
  }
  _updateMicTooltip();
})();
window._micActive=window._micActive||false;
window._micPendingSend=window._micPendingSend||false;

// ── Turn-based voice mode (#1333) ────────────────────────────────────────
// Chained flow: listen → send → (agent processes) → TTS response → listen again
(function(){
  const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
  const hasSTT=!(!SpeechRecognition);
  const hasTTS=!!('speechSynthesis' in window);

  // Need both STT and TTS for turn-based voice mode
  if(!hasSTT||!hasTTS) return;

  const modeBtn=$('btnVoiceMode');
  const bar=$('voiceModeBar');
  const indicator=$('voiceModeIndicator');
  const label=$('voiceModeLabel');
  const micBtn=$('btnMic');
  const ta=$('msg');

  if(!modeBtn||!bar||!indicator||!label) return;

  // Voice-mode button is gated behind a Preferences toggle (#1488).
  // Default off — keeps the composer footer uncluttered for users who
  // only need plain dictation. The hands-free conversation feature is
  // a power-user surface; explicit opt-in avoids the visual confusion
  // of two near-identical mic icons.
  function _voiceModePrefEnabled(){
    try{ return localStorage.getItem('hermes-voice-mode-button')==='true'; }
    catch(_){ return false; }
  }
  let _voiceModeActive=false;

  function _applyVoiceModePref(){
    const enabled = _voiceModePrefEnabled();
    modeBtn.style.display = enabled ? '' : 'none';
    if(!enabled && _voiceModeActive) _deactivate();
  }
  _applyVoiceModePref();
  // Expose so the settings pane can re-apply immediately on toggle.
  window._applyVoiceModePref = _applyVoiceModePref;

  let _voiceModeState='idle'; // idle | listening | thinking | speaking
  let _recognition=null;
  let _silenceTimer=null;
  // Capture the session id at thinking-time so the TTS callback won't read
  // a different session's last assistant reply if the user navigated away
  // between send and stream completion. (Opus pre-release advisor.)
  let _voiceModeThinkingSid=null;
  let _browserTtsKeepAlive=null;
  let _browserTtsWatchdog=null;
  let _browserTtsSuppressNextErrorRearm=false;
  const SILENCE_MS=1800; // auto-send after 1.8s silence

  function _clearBrowserTtsRecovery(){
    if(_browserTtsKeepAlive){
      clearInterval(_browserTtsKeepAlive);
      _browserTtsKeepAlive=null;
    }
    if(_browserTtsWatchdog){
      clearTimeout(_browserTtsWatchdog);
      _browserTtsWatchdog=null;
    }
  }

  function _armBrowserTtsRecovery(clean, rate){
    _clearBrowserTtsRecovery();
    _browserTtsSuppressNextErrorRearm=false;
    const safeRate=(Number.isFinite(rate)&&rate>0)?rate:1;
    // Chromium can drop utter.onend on later turns, so force a recovery path.
    const watchdogMs=Math.max(4000,Math.round((String(clean||'').length/(12*safeRate))*1000)+10000);
    _browserTtsWatchdog=setTimeout(()=>{
      if(!_voiceModeActive||_voiceModeState!=='speaking') return;
      _browserTtsSuppressNextErrorRearm=true;
      try{ speechSynthesis.cancel(); }catch(_){}
      _clearBrowserTtsRecovery();
      _startListening();
    },watchdogMs);
    _browserTtsKeepAlive=setInterval(()=>{
      if(!_voiceModeActive||_voiceModeState!=='speaking'){
        _clearBrowserTtsRecovery();
        return;
      }
      if(!speechSynthesis.speaking) return;
      try{
        speechSynthesis.pause();
        speechSynthesis.resume();
      }catch(_){}
    },10000);
  }

  function _setState(state){
    _voiceModeState=state;
    indicator.className='voice-mode-indicator '+state;
    label.textContent=state==='listening'?t('voice_listening')
      :state==='speaking'?t('voice_speaking')
      :state==='thinking'?t('voice_thinking')
      :'';
    bar.style.display=_voiceModeActive?(state==='idle'?'none':''):'none';
  }

  function _startListening(){
    if(!_voiceModeActive) return;
    _clearBrowserTtsRecovery();
    _setState('listening');

    _recognition=new SpeechRecognition();
    _recognition.continuous=false;
    _recognition.interimResults=true;
    _recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';

    let _finalText='';

    _recognition.onstart=()=>{ _finalText=''; };

    _recognition.onresult=(event)=>{
      // Reset silence timer on any result
      clearTimeout(_silenceTimer);
      let interim='';
      let final=_finalText;
      for(let i=event.resultIndex;i<event.results.length;i++){
        const txt=event.results[i][0].transcript;
        if(event.results[i].isFinal){ final+=txt; _finalText=final; }
        else{ interim+=txt; }
      }
      ta.value=final||interim;
      autoResize();

      // Auto-send on silence after final result
      if(_finalText){
        _silenceTimer=setTimeout(()=>{
          _voiceModeSend();
        },SILENCE_MS);
      }
    };

    _recognition.onend=()=>{
      clearTimeout(_silenceTimer);
      // If we have text and haven't sent yet, send it
      if(_finalText&&_voiceModeActive&&_voiceModeState==='listening'){
        _voiceModeSend();
      } else if(_voiceModeActive&&_voiceModeState==='listening'){
        // No speech detected — restart listening
        setTimeout(()=>{ if(_voiceModeActive) _startListening(); },500);
      }
    };

    _recognition.onerror=(event)=>{
      clearTimeout(_silenceTimer);
      if(event.error==='no-speech'||event.error==='aborted'){
        // Restart if still active
        if(_voiceModeActive){
          setTimeout(()=>{ if(_voiceModeActive) _startListening(); },800);
        }
        return;
      }
      if(event.error==='not-allowed'||event.error==='service-not-allowed'||event.error==='audio-capture'){
        _deactivate();
        showToast(t('mic_denied'));
        return;
      }
      // Other errors — try to restart
      if(_voiceModeActive){
        setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1500);
      }
    };

    try{ _recognition.start(); }catch(e){
      // Already started or other error — retry shortly
      setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1000);
    }
  }

  function _voiceModeSend(){
    if(!_voiceModeActive) return;
    const text=(ta.value||'').trim();
    if(!text){
      ta.value='';
      setTimeout(()=>{ if(_voiceModeActive) _startListening(); },300);
      return;
    }
    _setState('thinking');
    // Pin the active session id so the TTS callback won't speak a different
    // session's reply if the user navigates away mid-stream.
    _voiceModeThinkingSid=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
    try{ if(_recognition) _recognition.abort(); }catch(_){}
    _recognition=null;
    // send() is global from boot.js
    if(typeof send==='function') send();
  }

  function _speakResponse(){
    if(!_voiceModeActive) return;
    // Bail out if the user navigated to a different session between send and
    // stream completion. The patched autoReadLastAssistant fires globally;
    // without this guard it would TTS-read the wrong session's last assistant
    // message. Drop back to listening on the new session instead.
    const currentSid=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
    if(_voiceModeThinkingSid && currentSid && currentSid!==_voiceModeThinkingSid){
      _voiceModeThinkingSid=null;
      _startListening();
      return;
    }
    _voiceModeThinkingSid=null;
    _setState('speaking');

    // Find last assistant message
    const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
    if(!rows.length){ _startListening(); return; }
    const last=rows[rows.length-1];
    const rawText=last.dataset.rawText||'';
    if(!rawText.trim()){ _startListening(); return; }

    // Strip for TTS (reuse existing helper if available)
    let clean=rawText;
    if(typeof _stripForTTS==='function') clean=_stripForTTS(rawText);
    else{
      // Basic strip: remove code blocks, images, links
      clean=clean.replace(/```[\s\S]*?```/g,' code block ')
        .replace(/`([^`]*)`/g,'$1')
        .replace(/!\[([^\]]*)\]\([^)]*\)/g,'$1')
        .replace(/\[([^\]]*)\]\([^)]*\)/g,'$1')
        .replace(/#{1,6}\s/g,'')
        .replace(/[*_~]+/g,'')
        .replace(/\n{2,}/g,'. ')
        .replace(/\n/g,' ')
        .trim();
    }
    if(!clean){ _startListening(); return; }
    const engine=localStorage.getItem("hermes-tts-engine")||"browser";
    if(engine==="edge"){
      const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
      const savedRate=parseFloat(localStorage.getItem("hermes-tts-rate"));
      const savedPitch=parseFloat(localStorage.getItem("hermes-tts-pitch"));
      let rate='', pitch='';
      if(!isNaN(savedRate)){const pct=Math.round((savedRate-1)*100);const sign=pct>=0?'+':'';rate=sign+pct+'%';}
      if(!isNaN(savedPitch)){const hz=Math.round((savedPitch-1)*50);const sign=hz>=0?'+':'';pitch=sign+hz+'Hz';}
      _ttsSpeaking=true;
      fetch(new URL('api/tts', document.baseURI || location.href).href, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({text: clean, voice, rate, pitch})
      })
      .then(r => {
        if(!r.ok) throw new Error('TTS request failed: ' + r.status);
        return r.blob();
      })
      .then(blob => {
        const url = URL.createObjectURL(blob);
        const audio = new Audio(url);
        // Register with the shared handle (declared in ui.js, same global scope;
        // both scripts are fully evaluated before any voice interaction) so
        // stopTTS() — called from _deactivate() — can actually pause hands-free
        // Edge playback. Without this the audio is local here and unstoppable.
        _playingEdgeAudio=audio;
        audio.onended = () => {
          _ttsSpeaking=false;
          if(_playingEdgeAudio===audio) _playingEdgeAudio=null;
          URL.revokeObjectURL(url);
          if(_voiceModeActive) setTimeout(()=>_startListening(),500);
        };
        audio.onerror = () => {
          _ttsSpeaking=false;
          if(_playingEdgeAudio===audio) _playingEdgeAudio=null;
          URL.revokeObjectURL(url);
          if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
        };
        audio.play().catch(e => {
          _ttsSpeaking=false;
          if(_playingEdgeAudio===audio) _playingEdgeAudio=null;
          if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
        });
      })
      .catch(() => {
        _ttsSpeaking=false;
        if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
      });
      return;
    }
    const utter=new SpeechSynthesisUtterance(clean);

    // Apply saved voice preferences
    const savedVoice=localStorage.getItem('hermes-tts-voice');
    const voices=speechSynthesis.getVoices();
    if(savedVoice&&voices.length){
      const match=voices.find(v=>v.name===savedVoice);
      if(match) utter.voice=match;
    }
    const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
    if(!isNaN(savedRate)) utter.rate=Math.min(2,Math.max(0.5,savedRate));
    const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
    if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));

    utter.onend=()=>{
      _browserTtsSuppressNextErrorRearm=false;
      _clearBrowserTtsRecovery();
      // After speaking, go back to listening
      if(_voiceModeActive&&_voiceModeState==='speaking') setTimeout(()=>_startListening(),500);
    };
    utter.onerror=()=>{
      _clearBrowserTtsRecovery();
      if(_browserTtsSuppressNextErrorRearm){
        _browserTtsSuppressNextErrorRearm=false;
        return;
      }
      if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
    };

    _armBrowserTtsRecovery(clean, utter.rate);
    try{
      speechSynthesis.speak(utter);
    }catch(_){
      _clearBrowserTtsRecovery();
      if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
    }
  }

  // Hook into response completion — observe when the agent finishes
  // We patch setComposerStatus to detect when a response completes
  const _origSetComposerStatus=(typeof setComposerStatus==='function')?setComposerStatus.bind(window):null;

  window._voiceModeOnResponseComplete=function(){
    if(_voiceModeActive&&_voiceModeState==='thinking'){
      // Small delay to let DOM render the final message
      setTimeout(()=>{
        if(_voiceModeActive&&_voiceModeState==='thinking'){
          _speakResponse();
        }
      },400);
    }
  };

  // Observe S.busy changes to detect response completion
  // The existing code calls setBusy(false) when response completes
  const _origSetBusy=(typeof setBusy==='function')?setBusy.bind(window):null;
  if(_origSetBusy){
    // We use a MutationObserver-style approach via polling S.busy
    // Actually, we'll use a simpler approach: hook into the message stream completion
  }

  // Most reliable hook: use the existing autoReadLastAssistant call site.
  // We override autoReadLastAssistant so that if voice mode is active, we use our
  // own speak-and-resume flow instead of the default auto-read.
  const _origAutoRead=(typeof autoReadLastAssistant==='function')?autoReadLastAssistant:null;
  window.autoReadLastAssistant=function(){
    if(_voiceModeActive&&_voiceModeState==='thinking'){
      _speakResponse();
      return;
    }
    if(_origAutoRead) _origAutoRead.apply(this,arguments);
  };

  function _activate(){
    _voiceModeActive=true;
    modeBtn.classList.add('active');
    _setButtonTooltip(modeBtn, t('voice_mode_toggle_active'));
    showToast(t('voice_mode_active'),1500);
    // If the agent is busy, wait — state will be 'thinking' and we'll detect completion
    if(typeof S!=='undefined'&&S.busy){
      _setState('thinking');
      return;
    }
    // Cancel any existing TTS
    if(typeof stopTTS==='function') stopTTS();
    _startListening();
  }

  function _deactivate(){
    _voiceModeActive=false;
    _voiceModeState='idle';
    _voiceModeThinkingSid=null;
    _browserTtsSuppressNextErrorRearm=false;
    modeBtn.classList.remove('active');
    _setButtonTooltip(modeBtn, t('voice_mode_toggle'));
    bar.style.display='none';
    clearTimeout(_silenceTimer);
    _clearBrowserTtsRecovery();
    try{ if(_recognition) _recognition.abort(); }catch(_){}
    _recognition=null;
    if(typeof stopTTS==='function') stopTTS();
    // Restore original autoReadLastAssistant
    if(_origAutoRead) window.autoReadLastAssistant=_origAutoRead;
    // Clear textarea if it was only voice input
    ta.value='';
    autoResize();
  }

  modeBtn.onclick=()=>{
    if(_voiceModeActive){
      _deactivate();
      showToast(t('voice_mode_off'),1500);
    }else{
      _activate();
    }
  };

  // Expose for external use
  window._voiceModeActive=()=>_voiceModeActive;
  window._voiceModeDeactivate=_deactivate;
  window._voiceModeImmediateSend=_voiceModeSend;
})();
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
$('btnNewChat').onclick=async()=>{
  // If the current session has no messages AND nothing is in flight, just focus
  // the composer rather than creating another empty session that will clutter the
  // sidebar list (#1171).
  //
  // The "nothing in flight" half is critical (#1432): if the user clicks + while
  // their first message is still streaming (or queued), `message_count` is still 0
  // server-side because the user turn hasn't been merged yet. The old guard treated
  // that as "empty" and made + a no-op for the entire stream duration, so users
  // couldn't actually start a parallel chat. Use the same in-flight signal as
  // `_restoreSettledSession()` in messages.js: an active stream id or a queued
  // pending user message means the session is real, not empty.
  if(S.session
     && (S.session.message_count||0)===0
     && !S.busy
     && !S.session.active_stream_id
     && !S.session.pending_user_message){
    $('msg').focus();closeMobileSidebar();return;
  }
  if(typeof _restoreRememberedNewChatDraftSession==='function'
     && await _restoreRememberedNewChatDraftSession()){
    await renderSessionList();closeMobileSidebar();$('msg').focus();return;
  }
  await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
};
$('btnDownload').onclick=()=>{
  if(!S.session)return;
  const blob=new Blob([transcript()],{type:'text/markdown'});
  const a=document.createElement('a');a.href=URL.createObjectURL(blob);
  a.download=`hermes-${S.session.session_id}.md`;a.click();URL.revokeObjectURL(a.href);
};
$('btnExportJSON').onclick=()=>{
  if(!S.session)return;
  const url=`/api/session/export?session_id=${encodeURIComponent(S.session.session_id)}`;
  const a=document.createElement('a');a.href=url;
  a.download=`hermes-${S.session.session_id}.json`;a.click();
};
$('btnImportJSON').onclick=()=>$('importFileInput').click();
$('importFileInput').onchange=async(e)=>{
  const file=e.target.files[0];
  if(!file)return;
  e.target.value='';
  try{
    const text=await file.text();
    const data=JSON.parse(text);
    const res=await api('/api/session/import',{method:'POST',body:JSON.stringify(data)});
    if(res.ok&&res.session){
      await loadSession(res.session.session_id);
      await renderSessionList();
      if(_currentPanel==='settings') switchPanel('chat');
      showToast(t('session_imported'));
    }
  }catch(err){
    showToast(t('import_failed')+(err.message||t('import_invalid_json')));
  }
};
// btnRefreshFiles is now panel-icon-btn in header (see HTML)
function clearPreview(opts={}){
  const keepPanelOpen=!!(opts&&opts.keepPanelOpen);
  // Restore directory breadcrumb after closing file preview
  if(typeof renderBreadcrumb==='function') renderBreadcrumb();
  const closePanelAfter=_workspacePanelMode==='preview'&&!keepPanelOpen;
  const pa=$('previewArea');if(pa)pa.classList.remove('visible');
  const pi=$('previewImg');if(pi){pi.onerror=null;pi.src='';}
  const pdf=$('previewPdfFrame');if(pdf)pdf.src='';
  const html=$('previewHtmlIframe');if(html)html.src='';
  const pm=$('previewMd');if(pm)pm.innerHTML='';
  const pc=$('previewCode');if(pc)pc.textContent='';
  const pp=$('previewPathText');if(pp)pp.textContent='';
  const ft=$('fileTree');if(ft)ft.style.display='';
  _previewCurrentPath='';_previewCurrentMode='';_previewDirty=false;
  if(closePanelAfter)closeWorkspacePanel();
  else if(keepPanelOpen&&_workspacePanelMode==='preview')openWorkspacePanel('browse');
  else syncWorkspacePanelUI();
}
$('btnClearPreview').onclick=handleWorkspaceClose;
// workspacePath click handler removed -- use topbar workspace chip dropdown instead
function _applySessionContextMetadataUpdate(data){
  if(!S.session||!data||!data.session)return;
  S.session.context_length=data.session.context_length||0;
  S.session.threshold_tokens=data.session.threshold_tokens||0;
  S.session.last_prompt_tokens=data.session.last_prompt_tokens||0;
  if(typeof _syncCtxIndicator==='function'){
    const u=S.lastUsage||{};
    const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt);
    _syncCtxIndicator({
      input_tokens:_pick(u.input_tokens,S.session.input_tokens),
      output_tokens:_pick(u.output_tokens,S.session.output_tokens),
      estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost),
      context_length:S.session.context_length||0,
      last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens),
      threshold_tokens:S.session.threshold_tokens||0,
    });
  }
}

$('modelSelect').onchange=async()=>{
  const selectedModel=$('modelSelect').value;
  const modelState=(typeof _modelStateForSelect==='function')
    ? _modelStateForSelect($('modelSelect'),selectedModel)
    : {model:selectedModel,model_provider:null};
  if(typeof closeModelDropdown==='function') closeModelDropdown();
  if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider);
  else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{}
  if(!S.session){
    if(typeof syncModelChip==='function') syncModelChip();
    if(typeof syncReasoningChip==='function') syncReasoningChip();
    return;
  }
  if(typeof _rememberPendingSessionModel==='function') _rememberPendingSessionModel(S.session.session_id,modelState.model,modelState.model_provider);
  S.session.model=modelState.model;
  S.session.model_provider=modelState.model_provider||null;
  if(typeof syncModelChip==='function') syncModelChip();
  if(typeof syncReasoningChip==='function') syncReasoningChip();
  syncTopbar();
  // Clarify scope: composer model changes are session-local, not the global default.
  if(typeof showToast==='function'){
    showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
  }
  const data=await api('/api/session/update',{method:'POST',body:JSON.stringify({
    session_id:S.session.session_id,
    workspace:S.session.workspace,
    model:modelState.model,
    model_provider:modelState.model_provider||null,
  })});
  // NOTE: do NOT clear the pending explicit-pick marker here. It must survive until
  // the NEXT send() consumes it, otherwise the normal "pick → session-update → send"
  // flow loses the explicit-pick signal before /api/chat/start runs and the server
  // re-reverts a cross-family pick (the #3737 bug, Codex catch). send() clears it
  // after reading a matching pending pick. (#3739/#3737)
  _applySessionContextMetadataUpdate(data);
  // 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',()=>{
  updateSendBtn();
  scheduleComposerAutoResize();
  // Persist composer draft to server (debounced in _saveComposerDraft).
  const sid = S && S.session && S.session.session_id;
  if (sid && typeof _saveComposerDraft === 'function') {
    _saveComposerDraft(sid, $('msg').value, S.pendingFiles ? [...S.pendingFiles] : []);
  }
  const text=$('msg').value;
  if(text.startsWith('/')&&text.indexOf('\n')===-1){
    if(typeof getSlashAutocompleteMatches==='function'){
      getSlashAutocompleteMatches(text).then(matches=>{
        if(($('msg').value||'')!==text) return;
        if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
      });
    }else{
      const prefix=text.slice(1);
      const matches=getMatchingCommands(prefix);
      if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
    }
    if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete();
  } else if(typeof getComposerPathAutocompleteMatches==='function'){
    const cursor=$('msg').selectionStart;
    getComposerPathAutocompleteMatches(text,cursor).then(matches=>{
      const ta=$('msg');
      if(!ta||ta.value!==text||ta.selectionStart!==cursor) return;
      if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
    }).catch(()=>hideCmdDropdown());
  } else {
    hideCmdDropdown();
  }
});
// Track IME composition for East Asian input. Safari fires the committing
// keydown AFTER compositionend with isComposing=false, so we also keep a
// manual flag and reset it on the next tick to swallow that trailing Enter.
// Also reset on blur so the flag can never get stuck in a true state if
// compositionend never fires (focus loss with some IME implementations).
//
// The `_imeComposing` flag is bound to the chat composer (`#msg`); other
// inputs (session/project rename, app dialog, message edit, workspace rename)
// rely on the state-free `e.isComposing || e.keyCode === 229` part of
// `_isImeEnter`, which is sufficient for the Safari race because keyCode 229
// is the canonical "still composing" signal regardless of which field is
// focused. Promote `_isImeEnter` to `window` so other modules can reuse it
// without duplicating the full IIFE per input (issue #1443).
let _imeComposing=false;
(()=>{const _c=$('msg');if(!_c)return;
  _c.addEventListener('compositionstart',()=>{_imeComposing=true;});
  _c.addEventListener('compositionend',()=>{setTimeout(()=>{_imeComposing=false;},0);});
  _c.addEventListener('blur',()=>{_imeComposing=false;});
})();
function _isImeEnter(e){return e.isComposing||e.keyCode===229||_imeComposing;}
window._isImeEnter=_isImeEnter;
function _isVirtualKeyboardLikelyOpen(){
  const vv=window.visualViewport;
  if(!vv||!window.innerHeight)return true;
  return window.innerHeight-vv.height>120;
}
// #3076: a touch-primary device (`pointer:coarse`) can still have a
// physical keyboard attached (Android tablet + Bluetooth keyboard,
// detachable Surface in tablet mode, iPad + Magic Keyboard). When that
// happens we should NOT force the mobile newline-on-Enter override
// because Shift+Enter / Ctrl+Enter come from real keys and the user
// expects desktop semantics. `matchMedia('(any-pointer:fine)')` is true
// whenever ANY available pointing device is fine-grained — which is the
// strongest signal browsers expose for "there is a real keyboard /
// trackpad in the picture too". Skip the mobile default in that case.
function _hasFinePointerCoexisting(){
  try{ return matchMedia('(any-pointer:fine)').matches; }catch(_){ return false; }
}
function _isNumpadEnter(e){
  return e.key==='Enter'&&(e.code==='NumpadEnter'||e.location===KeyboardEvent.DOM_KEY_LOCATION_NUMPAD);
}
$('msg').addEventListener('keydown',e=>{
  // Autocomplete navigation when dropdown is open
  const dd=$('cmdDropdown');
  const dropdownOpen=dd&&dd.classList.contains('open');
  if(dropdownOpen){
    if(e.key==='ArrowUp'){e.preventDefault();navigateCmdDropdown(-1);return;}
    if(e.key==='ArrowDown'){e.preventDefault();navigateCmdDropdown(1);return;}
    if(e.key==='Tab'){e.preventDefault();selectCmdDropdownItem();return;}
    if(e.key==='Escape'){e.preventDefault();hideCmdDropdown();return;}
    if(e.key==='Enter'&&!e.shiftKey){
      if(_isImeEnter(e)){return;}
      e.preventDefault();
      selectCmdDropdownItem();
      return;
    }
  }
  // Send key: respect user preference.
  // On touch-primary devices with the software keyboard open, default to
  // Enter = newline since there's no physical Shift key. Hardware keyboards on
  // tablets keep desktop behavior when the viewport is not keyboard-shrunk.
  // The 'ctrl+enter' setting also uses this behavior (Enter = newline).
  // Users can override in Settings by explicitly choosing 'enter' mode.
  if(e.key==='Enter'){
    if(_isImeEnter(e)){return;}
    const isNumpadEnter=_isNumpadEnter(e);
    const _mobileDefault=matchMedia('(pointer:coarse)').matches
      &&!_hasFinePointerCoexisting()
      &&window._sendKey==='enter'
      &&_isVirtualKeyboardLikelyOpen();
    if(window._sendKey==='ctrl+enter'||_mobileDefault){
      if(isNumpadEnter||e.ctrlKey||e.metaKey){e.preventDefault();send();}
    } else {
      if(!e.shiftKey){e.preventDefault();send();}
    }
  }
});
// B14: Cmd/Ctrl+K creates a new chat from anywhere
document.addEventListener('keydown',async e=>{
  // Cmd/Ctrl+B toggles desktop sidebar collapse (VS Code convention).
  // Skip when typing in an input/textarea/contenteditable so text-edit
  // shortcuts (e.g. bold in some embedded editors) are never stolen.
  if((e.metaKey||e.ctrlKey)&&!e.shiftKey&&!e.altKey&&(e.key==='b'||e.key==='B')){
    const t=e.target;
    const isText=t&&(t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable);
    if(!isText&&typeof toggleSidebar==='function'&&_isDesktopWidth()){
      e.preventDefault();
      toggleSidebar();
      return;
    }
  }
  // Enter on approval card = Allow once (when a button inside the card is focused or
  // card is visible and focus is not on an input/textarea/select)
  if(e.key==='Enter'&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey){
    const card=$('approvalCard');
    const tag=(document.activeElement||{}).tagName||'';
    if(card&&card.classList.contains('visible')&&tag!=='TEXTAREA'&&tag!=='INPUT'&&tag!=='SELECT'){
      e.preventDefault();
      if(typeof respondApproval==='function') respondApproval('once');
      return;
    }
  }
  if((e.metaKey||e.ctrlKey)&&e.key==='k'){
    e.preventDefault();
    // If the current session has no messages AND nothing is in flight, just focus
    // the composer rather than creating another empty session that will clutter
    // the sidebar list (#1171). See the matching guard in $('btnNewChat').onclick
    // and bug #1432 for why the in-flight check is needed.
    if(S.session
       && (S.session.message_count||0)===0
       && !S.busy
       && !S.session.active_stream_id
       && !S.session.pending_user_message){
      $('msg').focus();return;
    }
    // Cmd/Ctrl+K should always create a new conversation, even while the current
    // one is still streaming. The old !S.busy guard meant users had to wait for
    // a long generation to finish before they could start something new — exactly
    // the moment they want to switch context. newSession() leaves the in-flight
    // stream running on its own session; the user just gets a fresh blank one.
    await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
  }
  if(e.key==='Escape'){
    // Close onboarding overlay if open (skip/dismiss the wizard)
    const onboardingOverlay=$('onboardingOverlay');
    if(onboardingOverlay&&onboardingOverlay.style.display!=='none'){
      if(typeof skipOnboarding==='function') skipOnboarding();
      return;
    }
    // Close settings panel if active
    if(_currentPanel==='settings'){_closeSettingsPanel();return;}
    // Close workspace dropdown
    closeWsDropdown();
    // Clear session search
    const ss=$('sessionSearch');
    if(ss&&ss.value){
      if(typeof clearSessionSearch==='function') clearSessionSearch(false);
      else { ss.value=''; filterSessions(); }
    }
    // Cancel any active message edit
    const editArea=document.querySelector('.msg-edit-area');
    if(editArea){
      const bar=editArea.closest('.msg-row')&&editArea.closest('.msg-row').querySelector('.msg-edit-bar');
      if(bar){const cancel=bar.querySelector('.msg-edit-cancel');if(cancel)cancel.click();}
    }
  }
});
$('msg').addEventListener('paste',e=>{
  const items=Array.from(e.clipboardData?.items||[]);
  // When the clipboard carries BOTH text and an image (common from Notes,
  // Word, browsers, Slack — the OS attaches a rendered preview alongside
  // the plain text), prefer the text and let the browser paste normally.
  // Only intercept when the clipboard is image-only (true screenshot paste).
  // Tighten the image filter to kind==='file' so string items advertising an
  // image MIME (e.g. text/html with an embedded data URI) are not misclassified.
  const hasText=items.some(i=>i.kind==='string'&&(i.type==='text/plain'||i.type==='text/html'));
  const imageItems=items.filter(i=>i.kind==='file'&&i.type.startsWith('image/'));
  if(!imageItems.length||hasText)return;
  e.preventDefault();
  const pasteTs=Date.now();
  const files=imageItems.map((i,idx)=>{
    const blob=i.getAsFile();
    const ext=i.type.split('/')[1]||'png';
    const suffix=imageItems.length>1?`-${idx+1}`:'';
    return new File([blob],`screenshot-${pasteTs}${suffix}.${ext}`,{type:i.type});
  });
  addFiles(files);
  setStatus(t('image_pasted')+files.map(f=>f.name).join(', '));
});
document.querySelectorAll('.suggestion').forEach(btn=>{
  btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();};
});

function applyEmptyStateSuggestionPref(){
  if(!$('emptyState')) return;
  $('emptyState').classList.toggle('no-suggestions',window._hideEmptyStateSuggestions===true);
}

window.addEventListener('resize',()=>{
  _syncWorkspacePanelInlineWidth();
  syncWorkspacePanelState();
});

// Boot: restore last session or start fresh
// ── Resizable panels ──────────────────────────────────────────────────────
(function(){
  const SIDEBAR_MIN=180, SIDEBAR_MAX=420;
  const PANEL_MIN=180,   PANEL_MAX=1200;

  function initResize(handleId, targetEl, edge, minW, maxW, storageKey){
    const handle = $(handleId);
    if(!handle || !targetEl) return;

    // Restore saved width
    if(storageKey === 'hermes-panel-w'){
      _syncWorkspacePanelInlineWidth();
    }else{
      const saved = localStorage.getItem(storageKey);
      if(saved) targetEl.style.width = saved + 'px';
    }

    let startX=0, startW=0;

    handle.addEventListener('mousedown', e=>{
      e.preventDefault();
      startX = e.clientX;
      startW = targetEl.getBoundingClientRect().width;
      handle.classList.add('dragging');
      document.body.classList.add('resizing');

      const onMove = ev=>{
        const delta = edge==='right' ? ev.clientX - startX : startX - ev.clientX;
        const newW = Math.min(maxW, Math.max(minW, startW + delta));
        targetEl.style.width = newW + 'px';
      };
      const onUp = ()=>{
        handle.classList.remove('dragging');
        document.body.classList.remove('resizing');
        localStorage.setItem(storageKey, parseInt(targetEl.style.width));
        document.removeEventListener('mousemove', onMove);
        document.removeEventListener('mouseup', onUp);
      };
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
    });
  }

  // Run after DOM ready (called from boot)
  window._initResizePanels = function(){
    const sidebar    = document.querySelector('.sidebar');
    const rightpanel = document.querySelector('.rightpanel');
    initResize('sidebarResize',    sidebar,    'right', SIDEBAR_MIN, SIDEBAR_MAX, 'hermes-sidebar-w');
    initResize('rightpanelResize', rightpanel, 'left',  PANEL_MIN,   PANEL_MAX,   'hermes-panel-w');
  };
})();

// ── Appearance helpers (theme = light/dark/system, skin = accent color) ──────
const _THEMES=[
  {name:'Light', value:'light', colors:['#FEFCF7','#FAF7F0','#B8860B']},
  {name:'Dark', value:'dark', colors:['#0D0D1A','#141425','#FFD700']},
  {name:'System', value:'system', colors:['#FEFCF7','#0D0D1A','#B8860B']},
];
const _SKINS=[
  {name:'Default',  colors:['#FFD700','#FFBF00','#CD7F32']},
  {name:'Ares',     colors:['#FF4444','#CC3333','#992222']},
  {name:'Mono',     colors:['#CCCCCC','#999999','#666666']},
  {name:'Graphite', colors:['#FFFFFF','#D6D6D6','#242424']},
  {name:'Slate',    colors:['#334155','#475569','#64748b']},
  {name:'Poseidon', colors:['#0EA5E9','#0284C7','#0369A1']},
  {name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
  {name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
  {name:'Sienna',   colors:['#D97757','#C06A49','#9A523A']},
  {name:'Catppuccin',colors:['#CBA6F7','#B4BEFE','#8839EF']},
  {name:'Hepburn',   colors:['#c6246a','#ec5597','#f2abca']},
  {name:'Nous',     colors:['#4682B4','#3A6E9A','#2C5F88']},
  {name:'Neon',     colors:['#B347FF','#C76BFF','#00DDFF']},
  {name:'Geist Contrast', value:'geist-contrast', colors:['#000000','#ffffff','#FFF175']},
  {name:'Zeus',     colors:['#FFD700','#FFBF00','#1A1A00']},
  {name:'Verdigris', value:'verdigris', colors:['#C89A5A','#0F1714','#22342C']},
];
const _VALID_THEMES=new Set((_THEMES||[]).map(t=>t.value));
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>(s.value||s.name).toLowerCase()));
const _LEGACY_THEME_MAP={
  slate:{theme:'dark',skin:'slate'},
  solarized:{theme:'dark',skin:'poseidon'},
  monokai:{theme:'dark',skin:'sisyphus'},
  nord:{theme:'dark',skin:'slate'},
  oled:{theme:'dark',skin:'default'},
};
let _systemThemeMq=null;
let _onSystemThemeChange=null;

function _normalizeAppearance(theme,skin){
  const rawTheme=typeof theme==='string'?theme.trim().toLowerCase():'';
  const rawSkin=typeof skin==='string'?skin.trim().toLowerCase():'';
  const legacy=_LEGACY_THEME_MAP[rawTheme];
  const nextTheme=legacy?legacy.theme:(_VALID_THEMES.has(rawTheme)?rawTheme:'dark');
  const nextSkin=_VALID_SKINS.has(rawSkin)?rawSkin:(legacy?legacy.skin:'default');
  return {theme:nextTheme,skin:nextSkin};
}

// Sync <meta name="theme-color"> with the active theme's app chrome color.
// This surfaces the WebUI's exact theme background to:
//   1. Mobile Safari status bar (the prefers-color-scheme media variants in index.html
//      cover the pre-load case; this updater handles user-toggled changes mid-session).
//   2. iOS PWA / Add to Home Screen status bar.
//   3. Native WKWebView wrappers (e.g. hermes-swift-mac) that read this attribute as
//      the source of truth for AppKit chrome (tab bar, title bar, traffic-light area)
//      instead of pixel-sampling — overlay-resistant and IPC-free.
// Reading getComputedStyle(html).getPropertyValue('--sidebar') picks up the active skin
// (Default, Sienna, Sisyphus, Charizard, etc.) so each skin's distinct paint reaches
// the meta tag.
function _syncThemeColorMeta(){
  try{
    const bg=getComputedStyle(document.documentElement).getPropertyValue('--sidebar').trim();
    if(!bg) return;
    const known=document.getElementById('hermes-theme-color');
    if(known){
      known.setAttribute('content',bg);
      known.removeAttribute('media');
    }
    document.querySelectorAll('meta[name="theme-color"]').forEach(meta=>{
      meta.setAttribute('content',bg);
      meta.removeAttribute('media');
    });
  }catch(e){}
}

function _setResolvedTheme(isDark){
  document.documentElement.classList.toggle('dark',!!isDark);
  const link=document.getElementById('prism-theme');
  if(!link){ _syncThemeColorMeta(); return; }
  const want=isDark
    ?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
    :'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
  // No SRI integrity on theme CSS — jsdelivr edge nodes serve different
  // digests for the same pinned version, causing intermittent blocking (#1100).
  if(link.href!==want){ link.integrity=''; link.href=want; }
  _syncThemeColorMeta();
}

function _applyTheme(name){
  const normalized=_normalizeAppearance(name,'default');
  delete document.documentElement.dataset.theme;
  if(_systemThemeMq&&_onSystemThemeChange){
    _systemThemeMq.removeEventListener('change',_onSystemThemeChange);
    _systemThemeMq=null;
    _onSystemThemeChange=null;
  }
  if(normalized.theme==='system'){
    _systemThemeMq=window.matchMedia('(prefers-color-scheme:dark)');
    _onSystemThemeChange=()=>_setResolvedTheme(_systemThemeMq.matches);
    _setResolvedTheme(_systemThemeMq.matches);
    _systemThemeMq.addEventListener('change',_onSystemThemeChange);
    return;
  }
  _setResolvedTheme(normalized.theme==='dark');
}

function _applySkin(name){
  const key=(name||'default').toLowerCase();
  if(key==='default') delete document.documentElement.dataset.skin;
  else document.documentElement.dataset.skin=key;
  _syncThemeColorMeta();
}

function _pickTheme(name){
  const currentSkin=localStorage.getItem('hermes-skin');
  const appearance=_normalizeAppearance(name,currentSkin);
  localStorage.setItem('hermes-theme',appearance.theme);
  localStorage.setItem('hermes-skin',appearance.skin);
  _applyTheme(appearance.theme);
  _applySkin(appearance.skin);
  _syncThemePicker(appearance.theme);
  _syncSkinPicker(appearance.skin);
  const hidden=$('settingsTheme');
  if(hidden) hidden.value=appearance.theme;
  const skinHidden=$('settingsSkin');
  if(skinHidden) skinHidden.value=appearance.skin;
  if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave();
}

function _pickSkin(name){
  const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),name);
  localStorage.setItem('hermes-theme',appearance.theme);
  localStorage.setItem('hermes-skin',appearance.skin);
  _applyTheme(appearance.theme);
  _applySkin(appearance.skin);
  _syncThemePicker(appearance.theme);
  _syncSkinPicker(appearance.skin);
  const hidden=$('settingsSkin');
  if(hidden) hidden.value=appearance.skin;
  const themeHidden=$('settingsTheme');
  if(themeHidden) themeHidden.value=appearance.theme;
  if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave();
}

function _syncThemePicker(active){
  document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{
    btn.classList.toggle('active',btn.dataset.themeVal===active);
    btn.style.borderColor='';
    btn.style.boxShadow='';
  });
}

function _syncSkinPicker(active){
  document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{
    btn.classList.toggle('active',btn.dataset.skinVal===active);
    btn.style.borderColor='';
    btn.style.boxShadow='';
  });
}

function _applyFontSize(size){
  if(size&&size!=='default'){
    document.documentElement.dataset.fontSize=size;
  } else {
    delete document.documentElement.dataset.fontSize;
  }
}

function _pickFontSize(size){
  localStorage.setItem('hermes-font-size',size);
  _applyFontSize(size);
  _syncFontSizePicker(size);
  const hidden=$('settingsFontSize');
  if(hidden) hidden.value=size;
  if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave();
}

function _syncFontSizePicker(active){
  document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn').forEach(btn=>{
    btn.classList.toggle('active',btn.dataset.fontSizeVal===(active||'default'));
    btn.style.borderColor='';
    btn.style.boxShadow='';
  });
}

function _buildSkinPicker(activeSkin){
  const grid=$('skinPickerGrid');
  if(!grid) return;
  grid.innerHTML='';
  for(const skin of _SKINS){
    const key=(skin.value||skin.name).toLowerCase();
    const btn=document.createElement('button');
    btn.type='button';
    btn.className='skin-pick-btn';
    btn.dataset.skinVal=key;
    btn.style.cssText='border:1px solid var(--border2);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;background:none;transition:all .15s';
    btn.onclick=()=>_pickSkin(key);
    const dots=skin.colors.map(c=>`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c}"></span>`).join('');
    const label=skin.label||skin.name;
    btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${label}</span>`;
    grid.appendChild(btn);
  }
  _syncSkinPicker((activeSkin||'default').toLowerCase());
}

function applyBotName(){
  // The saved assistant name applies to the default profile only.
  // Non-default profiles use their own profile names.
  const name=assistantDisplayName();
  if(!S.session) 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';
    // Persist default workspace so the blank new-chat page can show it
    // and workspace actions (New file/folder) work before the first session (#804).
    if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
    window._showTokenUsage=!!s.show_token_usage;
    window._showQuotaChip=s.show_quota_chip===true;
    window._showConversationOutline=s.show_conversation_outline===true;
    document.documentElement.dataset.conversationOutline=window._showConversationOutline?'enabled':'disabled';
    if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference();
    window._hideEmptyStateSuggestions=s.hide_empty_state_suggestions===true;
    applyEmptyStateSuggestionPref();
    window._showTps=!!s.show_tps;
    window._fadeTextEffect=!!s.fade_text_effect;
    window._showCliSessions=!!s.show_cli_sessions;
    window._showPreviousMessagingSessions=!!s.show_previous_messaging_sessions;
    window._soundEnabled=!!s.sound_enabled;
    window._notificationsEnabled=!!s.notifications_enabled;
    window._whatsNewSummaryEnabled=!!s.whats_new_summary_enabled;
    window._showThinking=s.show_thinking!==false;
    window._simplifiedToolCalling=true;
    window._chatActivityDisplayMode=s.chat_activity_display_mode==='transparent_stream'?'transparent_stream':'compact_worklog';
    window._transparentStream=window._chatActivityDisplayMode==='transparent_stream';
    window._terminalAutoExpandOnOutput=!!s.terminal_auto_expand_on_output;
    window._worklogDetailsExpandedByDefault=!!(
      Object.prototype.hasOwnProperty.call(s,'worklog_details_expanded_default')
        ? s.worklog_details_expanded_default
        : s.activity_feed_expanded_default
    );
    window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
    window._pinnedSessionsLimit=parseInt(s.pinned_sessions_limit||3,10)||3;
    window._inflightStateLimits={
      maxSessions:parseInt(s.inflight_state_max_sessions||8,10)||8,
      messages:parseInt(s.inflight_state_max_messages||24,10)||24,
      toolCalls:parseInt(s.inflight_state_max_tool_calls||48,10)||48,
      stringChars:parseInt(s.inflight_state_max_string_chars||60000,10)||60000,
      jsonChars:parseInt(s.inflight_state_max_json_chars||1500000,10)||1500000,
    };
    window._busyInputMode=(s.busy_input_mode||'queue');
    window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
    window._autoScrollFollow=s.auto_scroll_follow!==false;
    window._botName=s.bot_name||'Hermes';
    if(s.default_model_provider) window._activeProvider=s.default_model_provider;
    if(s.default_model){
      window._defaultModel=s.default_model;
      const sel=$('modelSelect');
      if(sel&&typeof _applyModelToDropdown==='function'){
        // Fresh page boot must prefer the profile/server default over stale
        // browser-persisted model state. A restored session can still apply its
        // own persisted model later through loadSession(). Preserve the browser
        // keys for legacy/no-default fallback paths instead of deleting them.
        const existingDefaultOpt=Array.from(sel.options).find(o=>o.value===s.default_model);
        if(existingDefaultOpt&&window._activeProvider&&!existingDefaultOpt.dataset.provider){
          existingDefaultOpt.dataset.provider=window._activeProvider;
        }
        if(!existingDefaultOpt){
          const opt=document.createElement('option');
          opt.value=s.default_model;
          opt.textContent=typeof getModelLabel==='function'?getModelLabel(s.default_model):s.default_model;
          opt.dataset.custom='1';
          opt.dataset.provider=window._activeProvider||'';
          sel.querySelectorAll('option[data-custom]').forEach(o=>o.remove());
          sel.appendChild(opt);
        }
        _applyModelToDropdown(s.default_model,sel,window._activeProvider||null);
      }
    }
    window._sessionJumpButtonsEnabled=!!s.session_jump_buttons;
    // Reconcile appearance: prefer localStorage (what the user last saw) over
    // the server.  If they diverge (e.g. a previous autosave POST failed),
    // push the localStorage values back to the server so settings.json stays
    // in sync without ever clobbering the user's chosen theme/skin.
    //
    // Caveat: the pre-paint inline script in index.html normalises empty
    // localStorage into 'dark'/'default' BEFORE this code runs, so a truly
    // empty (new-browser) state is indistinguishable from a user who chose
    // the defaults.  To avoid blocking server→client sync on first visit we
    // only let localStorage override the server when it carries an explicit
    // user-selectable theme value or a NON-DEFAULT skin.  That keeps the
    // server in charge for empty first-visit state while preserving explicit
    // light/dark/system choices after a failed autosave.
    const srvAppearance=_normalizeAppearance(s.theme,s.skin);
    const lsTheme=(localStorage.getItem('hermes-theme')||'').trim().toLowerCase();
    const lsSkin=(localStorage.getItem('hermes-skin')||'').trim().toLowerCase();
    const lsAppearance=_normalizeAppearance(lsTheme||null,lsSkin||null);
    const lsHasExplicitSkin=lsSkin&&lsSkin!=='default';
    const lsHasExplicitTheme=lsTheme&&['system','light','dark'].includes(lsTheme);
    const theme=lsHasExplicitTheme?lsAppearance.theme:srvAppearance.theme;
    const skin=lsHasExplicitSkin?lsAppearance.skin:srvAppearance.skin;
    localStorage.setItem('hermes-theme',theme);
    _applyTheme(theme);
    localStorage.setItem('hermes-skin',skin);
    _applySkin(skin);
    // Reconcile: if localStorage and server disagree, push localStorage
    // values to the server so the next refresh won't revert.
    if((lsHasExplicitTheme||lsHasExplicitSkin)&&(theme!==srvAppearance.theme||skin!==srvAppearance.skin)){
      try{
        api('/api/settings',{method:'POST',body:JSON.stringify({theme,skin})});
      }catch(_){}
    }
    const fontSize=(s.font_size||localStorage.getItem('hermes-font-size')||'default');
    localStorage.setItem('hermes-font-size',fontSize);
    _applyFontSize(fontSize);
    if(typeof setLocale==='function'){
      const _lang=typeof resolvePreferredLocale==='function'
        ? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'))
        : (s.language || localStorage.getItem('hermes-lang') || 'en');
      setLocale(_lang);
      if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
    }
    // TTS: apply enabled state on boot so buttons show/hide correctly (#499)
    if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true');
  }catch(e){
    window._sendKey='enter';
    window._showTokenUsage=false;
    window._showQuotaChip=false;
    window._showConversationOutline=false;
    document.documentElement.dataset.conversationOutline='disabled';
    if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference();
    window._hideEmptyStateSuggestions=false;
    applyEmptyStateSuggestionPref();
    window._showTps=false;
    window._fadeTextEffect=false;
    window._showCliSessions=false;
    window._soundEnabled=false;
    window._notificationsEnabled=false;
    window._whatsNewSummaryEnabled=false;
    window._showThinking=true;
    window._simplifiedToolCalling=true;
    window._chatActivityDisplayMode='compact_worklog';
    window._transparentStream=false;
    window._terminalAutoExpandOnOutput=false;
    window._sessionJumpButtonsEnabled=false;
    window._sidebarDensity='compact';
    window._pinnedSessionsLimit=3;
    window._busyInputMode='queue';
    window._sessionEndlessScrollEnabled=false;
    window._autoScrollFollow=true;
    window._botName='Hermes';
    _bootSettings={check_for_updates:false};
    if(typeof setLocale==='function'){
      const _lang=typeof resolvePreferredLocale==='function'
        ? resolvePreferredLocale(null, localStorage.getItem('hermes-lang'))
        : (localStorage.getItem('hermes-lang') || 'en');
      setLocale(_lang);
      if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
    }
    if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true');
  }
  // Non-blocking update check (fire-and-forget, once per tab session)
  // ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
  const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
  if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
    const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':'');
    api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false})}).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';S.activeProfileIsDefault=!!p.is_default;}catch(e){S.activeProfile='default';S.activeProfileIsDefault=true;}
  applyBotName();
  // Update profile chip label immediately
  const profileLabel=$('profileChipLabel');
  if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
  // Fetch available models without blocking session restore. The static HTML
  // options are enough for first paint; the dynamic provider list can settle
  // after the saved session is visible.
  const _hydrateBootModelDropdown=()=>populateModelDropdown({preferProfileDefaultOnFreshBoot:true}).then(()=>{
    const sessionModelState=S.session&&S.session.model
      ? {model:S.session.model,model_provider:S.session.model_provider||null}
      : null;
    const savedState=(typeof _readPersistedModelState==='function')
      ? _readPersistedModelState()
      : (localStorage.getItem('hermes-webui-model')?{model:localStorage.getItem('hermes-webui-model'),model_provider:null}:null);
    // Active sessions are authoritative. On fresh boot without a restored
    // session, keep the profile/server default ahead of stale browser model
    // state when a default exists.
    const stateToApply=sessionModelState||(!window._defaultModel?savedState:null);
    const savedModel=stateToApply&&stateToApply.model;
    if(savedModel && $('modelSelect')){
      const applied=(typeof _applyModelToDropdown==='function')
        ? (sessionModelState
          ? _applyModelToDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null)
          : _applyModelToDropdown(savedState.model,$('modelSelect'),savedState.model_provider||null))
        : null;
      if(!applied) $('modelSelect').value=stateToApply.model;
      // If the value didn't take (model not in list), clear the bad pref only
      // for persisted browser preferences. Active sessions remain authoritative.
      if(!applied&&sessionModelState&&typeof _ensureModelOptionInDropdown==='function'){
        _ensureModelOptionInDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null);
      }
      else if(!applied&&!sessionModelState&&$('modelSelect').value!==stateToApply.model){
        if(typeof _clearPersistedModelState==='function') _clearPersistedModelState();
        else {
          localStorage.removeItem('hermes-webui-model');
          localStorage.removeItem('hermes-webui-model-state');
        }
      }
      else if(typeof syncModelChip==='function') syncModelChip();
    }
    if(S.session) syncTopbar();
  }).catch(e=>{
    window._modelDropdownReady=null;
    throw e;
  });
  const _startBootModelDropdown=()=>{
    const ready=window._modelDropdownReady;
    if(ready&&typeof ready.then==='function') return ready;
    const next=_hydrateBootModelDropdown();
    window._modelDropdownReady=next;
    return next;
  };
  window._modelDropdownReady=null;
  window._ensureModelDropdownReady=_startBootModelDropdown;
  setTimeout(()=>{
    try{Promise.resolve(_startBootModelDropdown()).catch(()=>{});}catch(_){}
  },0);
  // Start independent boot fetches without holding the conversation list behind
  // them. The sidebar can render from /api/sessions while workspace/onboarding
  // metadata settles in parallel.
  const _workspaceListReady=loadWorkspaceList();
  const _onboardingReady=_bootSettings.onboarding_completed?Promise.resolve(false):loadOnboardingWizard();
  // Render the session list before restoring the saved conversation so a stale
  // saved-session/client-side boot error cannot leave the sidebar empty forever.
  await renderSessionList();
  await _workspaceListReady;
  await _onboardingReady;
  _initResizePanels();
  // Workspace panel restore happens AFTER loadSession so we know if
  // the session has a workspace — prevents the snap-open-then-closed flash (#576).
  // Fix #822: clear any browser-restored value before first render. This
  // covers fresh page loads and reloads. The bfcache restore case is handled
  // separately below by a `pageshow` listener — the async IIFE here does NOT
  // re-run when the browser restores the page from bfcache.
  const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = '';
  if (typeof syncSessionSearchClear === 'function') syncSessionSearchClear();
  // Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load)
  if(typeof fetchReasoningChip==='function') fetchReasoningChip();
  if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator();
  const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
  const pwaLaunchAction=(window.HermesPWA&&typeof window.HermesPWA.launchAction==='function')
    ? window.HermesPWA.launchAction()
    : null;
  if(pwaLaunchAction==='new-chat'){
    try{
      await newSession(true);
      if(S.session) await _startBootModelDropdown();
      S._bootReady=true;
      syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();return;
    }catch(e){console.warn('[pwa] new-chat launch action failed', e);}
  }
  const savedLocal=localStorage.getItem('hermes-webui-session');
  const saved=urlSession||savedLocal;
  if(saved){
    try{
      if(!urlSession&&savedLocal&&await _savedSessionShouldStaySidebarOnly(savedLocal)){
        S.session=null; S.messages=[]; S.activeStreamId=null; S.busy=false;
        S._bootReady=true;
        syncTopbar();syncWorkspacePanelState();
        $('emptyState').style.display='';
        await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();
        return;
      }
      await loadSession(saved);
      // Hard refresh starts from the static HTML model list. Hydrate the live
      // catalog after the saved session is known, then re-apply that session's
      // model before S._bootReady lets syncModelChip reveal the composer label.
      // Otherwise the chip can display the static default (e.g. GPT-5.4 Mini)
      // even though S.session already points at the Codex/current model.
      if(S.session) await _startBootModelDropdown();
      // If the restored session has no messages it is an ephemeral scratch pad —
      // treat the page as a fresh start rather than resuming a blank conversation.
      // loadSession() already ran, so loadDir() has populated the workspace file tree.
      // Do NOT remove the session ID from localStorage — keeping it means every
      // subsequent refresh will also run loadSession() → loadDir() → files stay visible.
      // Removing it here caused the file tree to go blank on the second refresh
      // because the "no saved session" path never calls loadDir (#workspace-files).
      const _restoredInFlight = S.session && (
        S.session.active_stream_id ||
        S.session.pending_user_message
      );
      const _restoredDraft = (S.session && S.session.composer_draft) || {};
      const _restoredDraftText = String(_restoredDraft.text||'').trim();
      const _restoredDraftFiles = Array.isArray(_restoredDraft.files)
        ? _restoredDraft.files.filter(Boolean)
        : [];
      const _restoredHasDraft = !!(_restoredDraftText || _restoredDraftFiles.length);
      if(S.session && (S.session.message_count||0) === 0 && !_restoredInFlight && !_restoredHasDraft){
        S.session=null; S.messages=[];
        S._bootReady=true;
        // Restore panel pref before syncing so the workspace panel stays visible
        // even though there is no active session (#workspace-persist).
        const _ephPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
          || localStorage.getItem('hermes-webui-workspace-panel')==='open';
        if(_ephPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';
        syncTopbar();syncWorkspacePanelState();
        $('emptyState').style.display='';
        await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();
        return;
      }
      // Restore the panel from localStorage when the session has a workspace.
      // Preference key takes priority over runtime state so that closing
      // the panel via toolbar X doesn't suppress the "keep open" setting.
      const panelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
        || localStorage.getItem('hermes-webui-workspace-panel')==='open';
      if(S.session&&S.session.workspace&&panelPref&&!_isCompactWorkspaceViewport()){
        _workspacePanelMode='browse';
      }
      S._bootReady=true;
      syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
    catch(e){localStorage.removeItem('hermes-webui-session');}
  }
  // no saved session - show empty state, wait for user to hit +
  S._bootReady=true;
  syncTopbar();
  // Restore panel pref so the workspace panel stays visible on a fresh load if the
  // user had it open during their last session (#workspace-persist).
  const _freshPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
    || localStorage.getItem('hermes-webui-workspace-panel')==='open';
  if(_freshPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';
  syncWorkspacePanelState();
  $('emptyState').style.display='';
  await renderSessionList();
  // Start real-time gateway session sync if setting is enabled
  if(typeof startGatewaySSE==='function') startGatewaySSE();
})().catch(e=>{
  console.error('[hermes] boot failed', e);
  try{S._bootReady=true;}catch(_){}
  try{syncTopbar();}catch(_){}
  try{syncWorkspacePanelState();}catch(_){}
  try{$('emptyState').style.display='';}catch(_){}
  try{if(typeof renderSessionList==='function') void renderSessionList();}catch(_){}
});

// Fix #822 (bfcache path): when the browser restores the page from the
// back-forward cache, the async boot IIFE above does NOT re-run, but the
// DOM — including any stale value in #sessionSearch — IS restored.  A
// prior search string would silently hide all sessions via the filter in
// renderSessionListFromCache().  Clear the field and re-run the full layout
// sync whenever the page is restored from cache (`event.persisted === true`).
// Fix #1045: also re-run topbar/workspace/panel state so the rail and layout
// chrome aren't left in the stale bfcache snapshot.
window.addEventListener('pageshow', async (event) => {
  if (!event.persisted) return;  // fresh loads are handled by the IIFE above
  const _srch = document.getElementById('sessionSearch');
  if (_srch) _srch.value = '';
  if (typeof syncSessionSearchClear === 'function') syncSessionSearchClear();
  // Close any dropdowns/popovers that were open when the user navigated away.
  // bfcache freezes DOM state, so a dropdown left open remains open on restore.
  if (typeof closeModelDropdown === 'function') try { closeModelDropdown(); } catch (_) {}
  if (typeof closeReasoningDropdown === 'function') try { closeReasoningDropdown(); } catch (_) {}
  if (typeof closeWsDropdown === 'function') try { closeWsDropdown(); } catch (_) {}
  if (typeof closeProfileDropdown === 'function') try { closeProfileDropdown(); } catch (_) {}
  // BFCache restores the frozen DOM without rerunning boot. Refresh the active
  // session through the normal load path so in-flight sessions with
  // active_stream_id / pending_user_message can reattach like a reload restore.
  if (S.session && S.session.session_id && typeof loadSession === 'function') {
    try {
      await loadSession(S.session.session_id);
      if (S.session && S.session.session_id && typeof checkInflightOnBoot === 'function') {
        try { await checkInflightOnBoot(S.session.session_id); } catch (_) {}
      }
    } catch (_) {}
  }
  // Re-synchronise layout chrome that the boot IIFE sets up but bfcache
  // doesn't re-run. Each call is guarded so missing helpers degrade silently.
  if (typeof syncTopbar === 'function') try { syncTopbar(); } catch (_) {}
  if (typeof syncWorkspacePanelState === 'function') try { syncWorkspacePanelState(); } catch (_) {}
  if (typeof renderSessionListFromCache === 'function') {
    try { renderSessionListFromCache(); } catch (_) {}
  }
  // Restart the gateway SSE watcher — the persisted connection is dead after bfcache
  if (typeof startGatewaySSE === 'function') try { startGatewaySSE(); } catch (_) {}
  // Re-sync sidebar collapse state from localStorage. bfcache restored the
  // frozen DOM but another tab may have toggled the sidebar in the meantime.
  if (typeof _isSidebarCollapsed === 'function' && typeof toggleSidebar === 'function') {
    try {
      const _want = localStorage.getItem('hermes-webui-sidebar-collapsed') === '1';
      const _have = _isSidebarCollapsed();
      if (_want !== _have) toggleSidebar(_want);
      if (typeof _syncSidebarAria === 'function') _syncSidebarAria();
    } catch (_) {}
  }
});

async function shutdownServer() {
  const ok = await showConfirmDialog({
    title: (typeof t === 'function' ? t('settings_shutdown_confirm_title') : 'Stop Hermes WebUI'),
    message: (typeof t === 'function' ? t('settings_shutdown_confirm_message') : 'Stop the Hermes WebUI server?'),
    confirmLabel: (typeof t === 'function' ? t('settings_shutdown_confirm_btn') : 'Stop'),
    danger: true,
  });
  if (!ok) return;
  localStorage.setItem('hermes-webui-server-stopped', '1');
  try { var bc = new BroadcastChannel('hermes-webui-shutdown'); bc.postMessage('stop'); bc.close(); } catch(_) {}
  _showServerStopped();
  try { await api('/api/shutdown', { method: 'POST' }); } catch (_) {}
}

function _showServerStopped() {
  var stoppedMsg = (typeof t === 'function' ? t('settings_shutdown_stopped_message') : 'Server stopped. You can close this tab.');
  document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;color:var(--muted);font-family:system-ui,ui-sans-serif;font-size:14px"><p>' + stoppedMsg + '</p></div>';
}
