let _currentPanel = 'chat'; let _renamingAppTitlebar = false; // guard against re-entrant rename let _skillsData = null; // cached skills list let _cronList = null; // cached cron jobs (array) let _currentCronDetail = null; // full cron job object let _cronMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit' let _cronPreFormDetail = null; // snapshot of prior selection when entering a form let _currentWorkspaceDetail = null; // { path, name, is_default } let _workspaceMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit' let _workspacePreFormDetail = null; let _currentProfileDetail = null; // full profile object let _profileMode = 'empty'; // 'empty' | 'read' | 'create' let _profilePreFormDetail = null; let _pendingSettingsTargetPanel = null; // destination selected while settings had unsaved changes // Map of panel names → i18n keys for the app titlebar label. const APP_TITLEBAR_KEYS = { chat: 'tab_chat', tasks: 'tab_tasks', skills: 'tab_skills', memory: 'tab_memory', workspaces: 'tab_workspaces', profiles: 'tab_profiles', todos: 'tab_todos', settings: 'tab_settings', }; /** * Update the top app titlebar to reflect the current page or selected conversation. * On the chat panel, a selected session's title takes precedence over the page name. */ function syncAppTitlebar() { const titleEl = document.getElementById('appTitlebarTitle'); const subEl = document.getElementById('appTitlebarSub'); if (!titleEl) return; const panel = (typeof _currentPanel === 'string' && _currentPanel) ? _currentPanel : 'chat'; let mainText = ''; let subText = ''; if (panel === 'chat' && typeof S !== 'undefined' && S && S.session) { mainText = S.session.title || (typeof t === 'function' ? t('untitled') : 'Untitled'); const vis = Array.isArray(S.messages) ? S.messages.filter(m => m && m.role && m.role !== 'tool') : []; if (typeof t === 'function') subText = t('n_messages', vis.length); } else { const key = APP_TITLEBAR_KEYS[panel]; mainText = key && typeof t === 'function' ? t(key) : (panel.charAt(0).toUpperCase() + panel.slice(1)); } // Don't touch the element while an inline rename is in progress — replacing // the span with an input would fire a MutationObserver that calls // syncAppTitlebar again, destroying the input before the user finishes. if (_renamingAppTitlebar) return; titleEl.textContent = mainText; if (subEl) { if (subText) { subEl.textContent = subText; subEl.hidden = false; } else { subEl.textContent = ''; subEl.hidden = true; } } // Double-click on the titlebar title → rename the active session (same behaviour // as double-clicking a session title in the sidebar). Only active on the chat // panel when a session is open. titleEl.ondblclick = null; // remove any previous handler before adding a fresh one if (panel === 'chat' && typeof S !== 'undefined' && S && S.session) { titleEl.ondblclick = (e) => { e.stopPropagation(); e.preventDefault(); if (_renamingAppTitlebar) return; _renamingAppTitlebar = true; const inp = document.createElement('input'); inp.type = 'text'; inp.className = 'app-titlebar-rename-input'; inp.value = S.session.title || (typeof t === 'function' ? t('untitled') : 'Untitled'); // Prevent click/dblclick on the input from bubbling — we don't want // panel switches, session switches, or any other handler firing. ['click', 'mousedown', 'dblclick', 'pointerdown'].forEach(ev => inp.addEventListener(ev, e2 => e2.stopPropagation()) ); const finish = async (save) => { _renamingAppTitlebar = false; if (save) { const newTitle = inp.value.trim() || (typeof t === 'function' ? t('untitled') : 'Untitled'); S.session.title = newTitle; syncTopbar(); // update #topbarTitle in the chat header syncAppTitlebar(); // Update the sidebar list so the renamed title appears immediately. // _renderOneSession reads from _allSessions cache, so patch it there too. try { const _cached = typeof _allSessions !== 'undefined' && _allSessions.find(s => s && s.session_id === S.session.session_id); if (_cached) _cached.title = newTitle; } catch (_) {} if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache(); try { await api('/api/session/rename', { method: 'POST', body: JSON.stringify({ session_id: S.session.session_id, title: newTitle }) }); } catch (err) { if (typeof setStatus === 'function') setStatus('Rename failed: ' + err.message); } } inp.replaceWith(titleEl); syncAppTitlebar(); }; inp.onkeydown = e2 => { if (e2.key === 'Enter') { e2.preventDefault(); e2.stopPropagation(); finish(true); } if (e2.key === 'Escape') { e2.preventDefault(); e2.stopPropagation(); finish(false); } }; inp.onblur = () => finish(false); titleEl.replaceWith(inp); inp.focus(); inp.select(); }; } } function _beginSettingsPanelSession() { _settingsDirty = false; _settingsThemeOnOpen = localStorage.getItem('hermes-theme') || 'dark'; _settingsSkinOnOpen = localStorage.getItem('hermes-skin') || 'default'; _settingsFontSizeOnOpen = localStorage.getItem('hermes-font-size') || 'default'; _pendingSettingsTargetPanel = null; if (_settingsAppearanceAutosaveTimer) { clearTimeout(_settingsAppearanceAutosaveTimer); _settingsAppearanceAutosaveTimer = null; } _settingsAppearanceAutosaveRetryPayload = null; _resetSettingsPanelState(); } function _beforePanelSwitch(nextPanel) { if (_currentPanel !== 'settings' || nextPanel === 'settings') return true; if (_settingsDirty) { _pendingSettingsTargetPanel = nextPanel || 'chat'; _showSettingsUnsavedBar(); return false; } _revertSettingsPreview(); _pendingSettingsTargetPanel = null; _resetSettingsPanelState(); return true; } function _consumeSettingsTargetPanel(fallback = 'chat') { const target = (_pendingSettingsTargetPanel && _pendingSettingsTargetPanel !== 'settings') ? _pendingSettingsTargetPanel : fallback; _pendingSettingsTargetPanel = null; return target; } async function switchPanel(name, opts = {}) { const nextPanel = name || 'chat'; const prevPanel = _currentPanel; if (!opts.bypassSettingsGuard && !_beforePanelSwitch(nextPanel)) return false; if (prevPanel !== 'settings' && nextPanel === 'settings') _beginSettingsPanelSession(); _currentPanel = nextPanel; // Update nav tabs (rail + mobile sidebar-nav share data-panel) document.querySelectorAll('[data-panel]').forEach(t => t.classList.toggle('active', t.dataset.panel === nextPanel)); // Update panel views document.querySelectorAll('.panel-view').forEach(p => p.classList.remove('active')); const panelEl = $('panel' + nextPanel.charAt(0).toUpperCase() + nextPanel.slice(1)); if (panelEl) panelEl.classList.add('active'); // Toggle main content view. Each entry in MAIN_VIEW_PANELS gets a matching // showing- class on
; no class means chat (the default). const mainEl = document.querySelector('main.main'); if (mainEl) { ['settings','skills','memory','tasks','workspaces','profiles'].forEach(p => { mainEl.classList.toggle('showing-' + p, nextPanel === p); }); } // Lazy-load panel data if (nextPanel === 'tasks') await loadCrons(); if (nextPanel === 'skills') await loadSkills(); if (nextPanel === 'memory') await loadMemory(); if (nextPanel === 'workspaces') await loadWorkspacesPanel(); if (nextPanel === 'profiles') await loadProfilesPanel(); if (nextPanel === 'todos') loadTodos(); if (nextPanel === 'settings') { switchSettingsSection(_currentSettingsSection); loadSettingsPanel(); } syncAppTitlebar(); return true; } // ── Cron panel ── function _isRecurringCronJob(job) { const kind = job && job.schedule && job.schedule.kind; return kind === 'cron' || kind === 'interval'; } function _hasUnlimitedRepeat(job) { return !!(job && job.repeat && job.repeat.times == null); } function _isCronNeedsAttention(job) { return _isRecurringCronJob(job) && _hasUnlimitedRepeat(job) && job.enabled === false && job.state === 'completed' && !job.next_run_at; } function _isCronScheduleError(job) { return _isRecurringCronJob(job) && !job.next_run_at && (job.state === 'error' || job.last_status === 'error'); } function _cronStatusMeta(job) { if (_isCronNeedsAttention(job)) return { state: 'needs_attention', listClass: 'attention', detailClass: 'warn', label: t('cron_status_needs_attention'), }; if (_isCronScheduleError(job)) return { state: 'schedule_error', listClass: 'attention', detailClass: 'warn', label: t('cron_status_needs_attention'), }; if (job.state === 'paused') return { state: 'paused', listClass: 'paused', detailClass: 'warn', label: t('cron_status_paused'), }; if (job.enabled === false) return { state: 'off', listClass: 'disabled', detailClass: 'warn', label: t('cron_status_off'), }; if (job.last_status === 'error') return { state: 'error', listClass: 'error', detailClass: 'err', label: t('cron_status_error'), }; return { state: 'active', listClass: 'active', detailClass: 'ok', label: t('cron_status_active'), }; } function _cronDiagnostics(job) { const fields = { id: job.id, name: job.name || null, schedule: job.schedule || null, schedule_display: job.schedule_display || null, enabled: job.enabled, state: job.state, next_run_at: job.next_run_at || null, last_run_at: job.last_run_at || null, last_status: job.last_status || null, last_error: job.last_error || null, last_delivery_error: job.last_delivery_error || null, repeat: job.repeat || null, deliver: job.deliver || null, }; return JSON.stringify(fields, null, 2); } async function loadCrons(animate) { const box = $('cronList'); const refreshBtn = $('cronRefreshBtn'); if (animate && refreshBtn) { refreshBtn.style.opacity = '0.5'; refreshBtn.disabled = true; } try { const data = await api('/api/crons'); _cronList = data.jobs || []; if (!_cronList.length) { box.innerHTML = `
${esc(t('cron_no_jobs'))}
`; if (_cronMode !== 'create' && _cronMode !== 'edit') _clearCronDetail(); return; } box.innerHTML = ''; for (const job of _cronList) { const item = document.createElement('div'); item.className = 'cron-item'; item.id = 'cron-' + job.id; const status = _cronStatusMeta(job); const isNewRun = _cronNewJobIds.has(String(job.id)); item.innerHTML = `
${isNewRun ? '' : ''} ${esc(job.name)} ${esc(status.label)}
`; item.onclick = () => openCronDetail(job.id, item); if (_currentCronDetail && _currentCronDetail.id === job.id) item.classList.add('active'); box.appendChild(item); } // Re-render current detail with fresh data if we have one and we're not in a form if (_currentCronDetail && _cronMode !== 'create' && _cronMode !== 'edit') { const refreshed = _cronList.find(j => j.id === _currentCronDetail.id); if (refreshed) _renderCronDetail(refreshed); else _clearCronDetail(); } } catch(e) { box.innerHTML = `
${esc(t('error_prefix'))}${esc(e.message)}
`; } finally { if (animate && refreshBtn) { refreshBtn.style.opacity = ''; refreshBtn.disabled = false; } } } function _renderCronDetail(job){ _currentCronDetail = job; const title = $('taskDetailTitle'); const body = $('taskDetailBody'); const empty = $('taskDetailEmpty'); if (!title || !body) return; title.textContent = job.name || job.schedule_display || '(unnamed)'; const status = _cronStatusMeta(job); const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : t('not_available'); const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : t('never'); const schedule = job.schedule_display || (job.schedule && job.schedule.expression) || ''; const skills = Array.isArray(job.skills) && job.skills.length ? job.skills.join(', ') : '—'; const deliver = job.deliver || 'local'; const lastError = job.last_error ? `
${esc(t('error_prefix').replace(/:\s*$/,''))}
${esc(job.last_error)}
` : ''; const attention = status.state === 'needs_attention' || status.state === 'schedule_error'; const croniterHint = job.last_error && /croniter/i.test(job.last_error) ? `

${esc(t('cron_attention_croniter_hint'))}

` : ''; const attentionBanner = attention ? `
${esc(t('cron_status_needs_attention'))}

${esc(t('cron_attention_desc'))}

${croniterHint}
` : ''; body.innerHTML = `
${attentionBanner}
${esc(t('cron_status_active').replace(/./,c=>c.toUpperCase()))}
Status
${esc(status.label)}
Schedule
${esc(schedule)}
${esc(t('cron_next'))}
${esc(nextRun)}
${esc(t('cron_last'))}
${esc(lastRun)}
Deliver
${esc(deliver)}
Skills
${esc(skills)}
${lastError}
Prompt
${esc(job.prompt || '')}
${esc(t('cron_last_output'))}
${esc(t('loading'))}
`; body.style.display = ''; if (empty) empty.style.display = 'none'; _cronMode = 'read'; _setCronHeaderButtons('read', job); // Load runs asynchronously _loadCronDetailRuns(job.id); } function _setCronHeaderButtons(mode, job) { const runBtn = $('btnRunTaskDetail'); const pauseBtn = $('btnPauseTaskDetail'); const resumeBtn = $('btnResumeTaskDetail'); const editBtn = $('btnEditTaskDetail'); const dupBtn = $('btnDuplicateTaskDetail'); const delBtn = $('btnDeleteTaskDetail'); const cancelBtn = $('btnCancelTaskDetail'); const saveBtn = $('btnSaveTaskDetail'); const hide = b => b && (b.style.display = 'none'); const show = b => b && (b.style.display = ''); if (mode === 'read') { show(runBtn); const status = job ? _cronStatusMeta(job) : null; const resumable = job && ( job.state === 'paused' || (status && (status.state === 'needs_attention' || status.state === 'schedule_error')) ); if (resumable) { hide(pauseBtn); show(resumeBtn); } else { show(pauseBtn); hide(resumeBtn); } show(editBtn); show(dupBtn); show(delBtn); hide(cancelBtn); hide(saveBtn); } else if (mode === 'create' || mode === 'edit') { hide(runBtn); hide(pauseBtn); hide(resumeBtn); hide(editBtn); hide(dupBtn); hide(delBtn); show(cancelBtn); show(saveBtn); } else { [runBtn,pauseBtn,resumeBtn,editBtn,dupBtn,delBtn,cancelBtn,saveBtn].forEach(hide); } } async function _loadCronDetailRuns(jobId){ try { const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20`); if (!_currentCronDetail || _currentCronDetail.id !== jobId) return; const card = $('cronDetailRuns'); if (!card) return; if (!data.outputs || !data.outputs.length) { card.innerHTML = `
${esc(t('cron_last_output'))}
${esc(t('cron_no_runs_yet'))}
`; return; } const rows = data.outputs.map((out, i) => { const ts = out.filename.replace('.md','').replace(/_/g,' '); const snippet = _cronOutputSnippet(out.content); const rid = `cron-det-run-${jobId}-${i}`; return `
${esc(ts)}
${esc(snippet)}
`; }).join(''); card.innerHTML = `
${esc(t('cron_last_output'))}
${rows}`; } catch(e) { /* ignore */ } } function openCronDetail(id, el){ const job = _cronList ? _cronList.find(j => j.id === id) : null; if (!job) return; document.querySelectorAll('.cron-item').forEach(e => e.classList.remove('active')); const target = el || $('cron-' + id); if (target) target.classList.add('active'); // Remove new-run dot from this job since user is now viewing it _clearCronUnreadForJob(id); const dot = target && target.querySelector('.cron-new-dot'); if (dot) dot.remove(); _cronPreFormDetail = null; _editingCronId = null; _stopCronWatch(); _renderCronDetail(job); _checkCronWatchOnDetail(id); } function _clearCronDetail(){ _currentCronDetail = null; _cronMode = 'empty'; _stopCronWatch(); const title = $('taskDetailTitle'); const body = $('taskDetailBody'); const empty = $('taskDetailEmpty'); if (title) title.textContent = ''; if (body) { body.innerHTML = ''; body.style.display = 'none'; } if (empty) empty.style.display = ''; _setCronHeaderButtons('empty'); } async function runCurrentCron(){ if (_currentCronDetail) await cronRun(_currentCronDetail.id); } async function pauseCurrentCron(){ if (_currentCronDetail) await cronPause(_currentCronDetail.id); } async function resumeCurrentCron(){ if (_currentCronDetail) await cronResume(_currentCronDetail.id); } async function copyCurrentCronDiagnostics(){ if (!_currentCronDetail) return; try { await _copyText(_cronDiagnostics(_currentCronDetail)); showToast(t('cron_diagnostics_copied')); } catch(e) { showToast(t('copy_failed'), 4000); } } function editCurrentCron(){ if (!_currentCronDetail) return; openCronEdit(_currentCronDetail); } function duplicateCurrentCron(){ if (!_currentCronDetail) return; const job = _currentCronDetail; if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks'); _cronPreFormDetail = { ...job }; _editingCronId = null; _cronMode = 'create'; _cronIsDuplicate = true; _cronSelectedSkills = Array.isArray(job.skills) ? [...job.skills] : []; // Deduplicate name: append "(copy)", "(copy 2)", "(copy 3)" etc. const baseName = job.name || ''; let dupName = baseName + ' (copy)'; if (_cronList && _cronList.length) { const taken = new Set(_cronList.filter(j => j.name).map(j => j.name)); if (taken.has(dupName)) { let n = 2; while (taken.has(baseName + ' (copy ' + n + ')')) n++; dupName = baseName + ' (copy ' + n + ')'; } } _renderCronForm({ name: dupName, schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '', prompt: job.prompt || '', deliver: job.deliver || 'local', isEdit: false, }); if (!_cronSkillsCache) { api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{}); } else { _bindCronSkillPicker(); } } async function deleteCurrentCron(){ if (!_currentCronDetail) return; const id = _currentCronDetail.id; const _ok = await showConfirmDialog({title:t('cron_delete_confirm_title'),message:t('cron_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true}); if(!_ok) return; try { await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})}); showToast(t('cron_job_deleted')); _clearCronDetail(); await loadCrons(); } catch(e) { showToast(t('delete_failed') + e.message, 4000); } } let _cronSelectedSkills=[]; let _cronIsDuplicate = false; let _cronSkillsCache=null; function openCronCreate(){ if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks'); _cronPreFormDetail = _currentCronDetail ? { ..._currentCronDetail } : null; _editingCronId = null; _cronMode = 'create'; _cronIsDuplicate = false; _cronSelectedSkills = []; _renderCronForm({ name:'', schedule:'', prompt:'', deliver:'local', isEdit:false }); _cronSkillsCache = null; api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{}); } function openCronEdit(job){ if (!job) return; _cronPreFormDetail = { ...job }; _editingCronId = job.id; _cronMode = 'edit'; _cronSelectedSkills = Array.isArray(job.skills) ? [...job.skills] : []; _renderCronForm({ name: job.name || '', schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '', prompt: job.prompt || '', deliver: job.deliver || 'local', isEdit: true, }); if (!_cronSkillsCache) { api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{}); } else { _bindCronSkillPicker(); } } function _renderCronForm({ name, schedule, prompt, deliver, isEdit }){ const title = $('taskDetailTitle'); const body = $('taskDetailBody'); const empty = $('taskDetailEmpty'); if (!body || !title) return; title.textContent = isEdit ? (t('edit') + ' · ' + (name || schedule || t('scheduled_jobs'))) : t('new_job'); const deliverOpt = (v,l) => ``; body.innerHTML = `
${esc(t('cron_schedule_hint') || "Cron expression or shorthand like 'every 1h'.")}
${isEdit ? `
${esc(t('cron_skills_edit_hint') || 'Skill list is not editable after creation.')}
` : ''}
`; body.style.display = ''; if (empty) empty.style.display = 'none'; _setCronHeaderButtons(isEdit ? 'edit' : 'create'); _renderCronSkillTags(); const focusEl = $('cronFormName'); if (focusEl) focusEl.focus(); } function _renderCronSkillTags(){ const wrap=$('cronFormSkillTags'); if(!wrap)return; wrap.innerHTML=''; for(const name of _cronSelectedSkills){ const tag=document.createElement('span'); tag.className='skill-tag'; tag.dataset.skill=name; const rm=document.createElement('span'); rm.className='remove-tag';rm.textContent='×'; rm.onclick=()=>{_cronSelectedSkills=_cronSelectedSkills.filter(s=>s!==name);tag.remove();}; tag.appendChild(document.createTextNode(name)); tag.appendChild(rm); wrap.appendChild(tag); } } function _bindCronSkillPicker(){ const search=$('cronFormSkillSearch'); const dropdown=$('cronFormSkillDropdown'); if(!search||!dropdown)return; search.oninput=()=>{ const q=search.value.trim().toLowerCase(); if(!q||!_cronSkillsCache){dropdown.style.display='none';return;} const matches=_cronSkillsCache.filter(s=> !_cronSelectedSkills.includes(s.name)&& (s.name.toLowerCase().includes(q)||(s.category||'').toLowerCase().includes(q)) ).slice(0,8); if(!matches.length){dropdown.style.display='none';return;} dropdown.innerHTML=''; for(const s of matches){ const opt=document.createElement('div'); opt.className='skill-opt'; opt.textContent=s.name+(s.category?' ('+s.category+')':''); opt.onclick=()=>{ _cronSelectedSkills.push(s.name); _renderCronSkillTags(); search.value=''; dropdown.style.display='none'; }; dropdown.appendChild(opt); } dropdown.style.display=''; }; search.onblur=()=>setTimeout(()=>{dropdown.style.display='none';},150); } function cancelCronForm(){ _editingCronId = null; if (_cronPreFormDetail) { const snap = _cronPreFormDetail; _cronPreFormDetail = null; _renderCronDetail(snap); return; } _cronPreFormDetail = null; _clearCronDetail(); } async function saveCronForm(){ const nameEl=$('cronFormName'); const schEl=$('cronFormSchedule'); const promptEl=$('cronFormPrompt'); const delivEl=$('cronFormDeliver'); const errEl=$('cronFormError'); if(!schEl||!promptEl||!errEl) return; const name=(nameEl?nameEl.value:'').trim(); const schedule=schEl.value.trim(); const prompt=promptEl.value.trim(); const deliver=delivEl?delivEl.value:'local'; errEl.style.display='none'; if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;} if(!prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;} try{ if (_editingCronId) { const updates = {job_id: _editingCronId, schedule, prompt}; if (name) updates.name = name; await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)}); const editedId = _editingCronId; _editingCronId = null; _cronPreFormDetail = null; showToast(t('cron_job_updated')); await loadCrons(); const job = _cronList && _cronList.find(j => j.id === editedId); if (job) openCronDetail(editedId); return; } const body={schedule,prompt,deliver}; if(_cronIsDuplicate) body.enabled=false; if(name)body.name=name; if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills; const res = await api('/api/crons/create',{method:'POST',body:JSON.stringify(body)}); _cronPreFormDetail = null; _cronIsDuplicate = false; showToast(t('cron_job_created')); await loadCrons(); const newId = res && (res.id || (res.job && res.job.id)); if (newId) openCronDetail(newId); else if (_cronList && _cronList.length) openCronDetail(_cronList[_cronList.length - 1].id); }catch(e){ errEl.textContent=t('error_prefix')+e.message;errEl.style.display=''; } } // Back-compat aliases for any stale callers const submitCronCreate = saveCronForm; function toggleCronForm(){ openCronCreate(); } function _cronOutputSnippet(content) { // Extract the response body from a cron output .md file const lines = content.split('\n'); const responseIdx = lines.findIndex(l => l.startsWith('## Response') || l.startsWith('# Response')); const body = (responseIdx >= 0 ? lines.slice(responseIdx + 1) : lines).join('\n').trim(); return body.slice(0, 600) || '(empty)'; } // ── Cron run watch ──────────────────────────────────────────────────────────── let _cronWatchInterval = null; let _cronWatchStart = null; let _cronWatchTimerInterval = null; function _startCronWatch(jobId) { _stopCronWatch(); _cronWatchStart = Date.now(); _cronWatchInterval = setInterval(async () => { try { const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`); if (!data.running) { _stopCronWatch(); if (_currentCronDetail && _currentCronDetail.id === jobId) { _loadCronDetailRuns(jobId); } return; } // Still running — update elapsed if (_currentCronDetail && _currentCronDetail.id === jobId) { const el = $('cronRunningIndicator'); if (el) el.querySelector('.cron-watch-elapsed').textContent = _formatElapsed(data.elapsed); } } catch(e) { /* ignore poll errors */ } }, 3000); // Timer update every second _cronWatchTimerInterval = setInterval(() => { if (_currentCronDetail && _cronWatchStart) { const el = $('cronRunningIndicator'); if (el) el.querySelector('.cron-watch-elapsed').textContent = _formatElapsed((Date.now() - _cronWatchStart) / 1000); } }, 1000); // Inject running indicator into detail card if (_currentCronDetail && _currentCronDetail.id === jobId) { _injectRunningIndicator(); } } function _stopCronWatch() { if (_cronWatchInterval) { clearInterval(_cronWatchInterval); _cronWatchInterval = null; } if (_cronWatchTimerInterval) { clearInterval(_cronWatchTimerInterval); _cronWatchTimerInterval = null; } _cronWatchStart = null; const el = $('cronRunningIndicator'); if (el) el.remove(); } function _injectRunningIndicator() { const card = $('cronDetailRuns'); if (!card || $('cronRunningIndicator')) return; const div = document.createElement('div'); div.id = 'cronRunningIndicator'; div.className = 'cron-running-indicator'; div.innerHTML = `${esc(t('cron_status_running'))}0s`; card.insertAdjacentElement('beforebegin', div); } function _formatElapsed(seconds) { if (seconds < 60) return Math.round(seconds) + 's'; const m = Math.floor(seconds / 60); const s = Math.round(seconds % 60); return m + 'm ' + s + 's'; } function _checkCronWatchOnDetail(jobId) { // When opening a detail view, check if job is running api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`).then(data => { if (data.running && _currentCronDetail && _currentCronDetail.id === jobId) { _startCronWatch(jobId); } }).catch(() => {}); } async function cronRun(id) { try { await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})}); showToast(t('cron_job_triggered')); _startCronWatch(id); } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } async function cronPause(id) { try { await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id})}); showToast(t('cron_job_paused')); await loadCrons(); } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } async function cronResume(id) { try { await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})}); showToast(t('cron_job_resumed')); await loadCrons(); } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } let _editingCronId = null; function loadTodos() { const panel = $('todoPanel'); if (!panel) return; const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages; // Parse the most recent todo state from message history let todos = []; for (let i = sourceMessages.length - 1; i >= 0; i--) { const m = sourceMessages[i]; if (m && m.role === 'tool') { try { const d = JSON.parse(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)); if (d && Array.isArray(d.todos) && d.todos.length) { todos = d.todos; break; } } catch(e) {} } } if (!todos.length) { panel.innerHTML = `
${esc(t('todos_no_active'))}
`; return; } const statusIcon = {pending:li('square',14), in_progress:li('loader',14), completed:li('check',14), cancelled:li('x',14)}; const statusColor = {pending:'var(--muted)', in_progress:'var(--blue)', completed:'rgba(100,200,100,.8)', cancelled:'rgba(200,100,100,.5)'}; panel.innerHTML = todos.map(t => `
${statusIcon[t.status]||li('square',14)}
${esc(t.content)}
${esc(t.id)} · ${esc(t.status)}
`).join(''); } async function clearConversation() { if(!S.session) return; const _clrMsg=await showConfirmDialog({title:t('clear_conversation_title'),message:t('clear_conversation_message'),confirmLabel:t('clear'),danger:true,focusCancel:true}); if(!_clrMsg) return; try { const data = await api('/api/session/clear', {method:'POST', body: JSON.stringify({session_id: S.session.session_id})}); S.session = data.session; S.messages = []; S.toolCalls = []; syncTopbar(); renderMessages(); showToast(t('conversation_cleared')); } catch(e) { setStatus(t('clear_failed') + e.message); } } // ── Skills panel ── async function loadSkills() { if (_skillsData) { renderSkills(_skillsData); return; } const box = $('skillsList'); try { const data = await api('/api/skills'); _skillsData = data.skills || []; // Prune collapsed state to only keep categories present in fresh data, // avoiding stale keys when categories are renamed or removed server-side. const liveCats = new Set(_skillsData.map(s => s.category || '(general)')); for (const c of _collapsedCats) { if (!liveCats.has(c)) _collapsedCats.delete(c); } renderSkills(_skillsData); } catch(e) { box.innerHTML = `
Error: ${esc(e.message)}
`; } } let _collapsedCats = new Set(); // persisted collapsed state across re-renders function _toggleCatCollapse(cat) { if (_collapsedCats.has(cat)) _collapsedCats.delete(cat); else _collapsedCats.add(cat); // Toggle DOM without full re-render document.querySelectorAll('.skills-category').forEach(sec => { const header = sec.querySelector('.skills-cat-header'); if (header && header.dataset.cat === cat) { const collapsed = _collapsedCats.has(cat); sec.classList.toggle('collapsed', collapsed); header.querySelector('.cat-chevron').style.transform = collapsed ? '' : 'rotate(90deg)'; sec.querySelectorAll('.skill-item').forEach(el => el.style.display = collapsed ? 'none' : ''); } }); } function renderSkills(skills) { const query = ($('skillsSearch').value || '').toLowerCase(); const filtered = query ? skills.filter(s => (s.name||'').toLowerCase().includes(query) || (s.description||'').toLowerCase().includes(query) || (s.category||'').toLowerCase().includes(query) ) : skills; // Group by category const cats = {}; for (const s of filtered) { const cat = s.category || '(general)'; if (!cats[cat]) cats[cat] = []; cats[cat].push(s); } const box = $('skillsList'); box.innerHTML = ''; if (!filtered.length) { box.innerHTML = `
${esc(t('skills_no_match'))}
`; return; } for (const [cat, items] of Object.entries(cats).sort()) { const collapsed = _collapsedCats.has(cat); const sec = document.createElement('div'); sec.className = 'skills-category' + (collapsed ? ' collapsed' : ''); const hdr = document.createElement('div'); hdr.className = 'skills-cat-header'; hdr.dataset.cat = cat; hdr.innerHTML = `${li('chevron-right',12)} ${esc(cat)} (${items.length})`; hdr.onclick = () => _toggleCatCollapse(cat); sec.appendChild(hdr); for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) { const el = document.createElement('div'); el.className = 'skill-item'; el.style.display = collapsed ? 'none' : ''; el.innerHTML = `${esc(skill.name)}${esc(skill.description||'')}`; el.onclick = () => openSkill(skill.name, el); sec.appendChild(el); } box.appendChild(sec); } } function filterSkills() { if (_skillsData) renderSkills(_skillsData); } // Currently selected skill detail — kept across panel switches so re-entering // the Skills view shows the last-viewed skill. let _currentSkillDetail = null; // { name, category, content } let _skillMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit' let _skillPreFormDetail = null; // snapshot of previously-viewed skill when entering a form let _editingSkillName = null; function _stripYamlFrontmatter(content) { if (!content) return { frontmatter: null, body: '' }; const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content); if (!m) return { frontmatter: null, body: content }; return { frontmatter: m[1], body: content.slice(m[0].length) }; } function _renderSkillDetail(name, content, linkedFiles) { const title = $('skillDetailTitle'); const body = $('skillDetailBody'); const empty = $('skillDetailEmpty'); const editBtn = $('btnEditSkillDetail'); const delBtn = $('btnDeleteSkillDetail'); if (title) title.textContent = name; const { frontmatter, body: markdownBody } = _stripYamlFrontmatter(content); let html = ''; if (frontmatter) { html += `
${esc(t('skill_metadata'))}
${esc(frontmatter)}
`; } html += renderMd(markdownBody || '(no content)'); const lf = linkedFiles || {}; const categories = Object.entries(lf).filter(([,files]) => files && files.length > 0); if (categories.length) { html += `
${esc(t('linked_files'))}
`; for (const [cat, files] of categories) { html += `

${esc(cat)}

`; for (const f of files) { html += `${esc(f)}`; } html += '
'; } html += '
'; } body.innerHTML = `
${html}
`; body.querySelectorAll('.skill-linked-file').forEach(a => { a.addEventListener('click', e => { e.preventDefault(); openSkillFile(a.dataset.skillName, a.dataset.skillFile); }); }); body.style.display = ''; if (empty) empty.style.display = 'none'; _skillMode = 'read'; _setSkillHeaderButtons('read'); } function _setSkillHeaderButtons(mode) { const editBtn = $('btnEditSkillDetail'); const delBtn = $('btnDeleteSkillDetail'); const cancelBtn = $('btnCancelSkillDetail'); const saveBtn = $('btnSaveSkillDetail'); const show = b => b && (b.style.display = ''); const hide = b => b && (b.style.display = 'none'); if (mode === 'read') { show(editBtn); show(delBtn); hide(cancelBtn); hide(saveBtn); } else if (mode === 'create' || mode === 'edit') { hide(editBtn); hide(delBtn); show(cancelBtn); show(saveBtn); } else { hide(editBtn); hide(delBtn); hide(cancelBtn); hide(saveBtn); } } async function openSkill(name, el) { // Highlight active skill in the sidebar list document.querySelectorAll('.skill-item').forEach(e => e.classList.remove('active')); if (el) el.classList.add('active'); _skillPreFormDetail = null; _editingSkillName = null; try { const data = await api(`/api/skills/content?name=${encodeURIComponent(name)}`); _currentSkillDetail = { name, content: data.content || '', linked_files: data.linked_files || {} }; _renderSkillDetail(name, data.content || '', data.linked_files || {}); } catch(e) { setStatus(t('skill_load_failed') + e.message); } } async function openSkillFile(skillName, filePath) { try { const data = await api(`/api/skills/content?name=${encodeURIComponent(skillName)}&file=${encodeURIComponent(filePath)}`); const body = $('skillDetailBody'); if (!body) return; const ext = (filePath.split('.').pop() || '').toLowerCase(); const isMd = ['md','markdown'].includes(ext); const backLabel = t('skills_back_to').replace('{0}', skillName); const header = `
← ${esc(backLabel)}${esc(filePath)}
`; let content; if (isMd) { content = `
${renderMd(data.content || '')}
`; } else { const escaped = esc(data.content || ''); content = `
${escaped}
`; } body.innerHTML = header + content; body.style.display = ''; const empty = $('skillDetailEmpty'); if (empty) empty.style.display = 'none'; body.querySelectorAll('.skill-file-back').forEach(a => { a.addEventListener('click', e => { e.preventDefault(); if (_currentSkillDetail && _currentSkillDetail.name === a.dataset.skillName) { _renderSkillDetail(_currentSkillDetail.name, _currentSkillDetail.content, _currentSkillDetail.linked_files); } else { openSkill(a.dataset.skillName, null); } }); }); if (!isMd) requestAnimationFrame(() => { if (typeof highlightCode === 'function') highlightCode(); }); } catch(e) { setStatus(t('skill_file_load_failed') + e.message); } } function editCurrentSkill() { if (!_currentSkillDetail) return; const s = _currentSkillDetail; let category = ''; if (_skillsData) { const match = _skillsData.find(x => x.name === s.name); if (match) category = match.category || ''; } _skillPreFormDetail = { name: s.name, content: s.content, linked_files: s.linked_files }; _editingSkillName = s.name; _skillMode = 'edit'; _renderSkillForm({ name: s.name, category, content: s.content || '', isEdit: true }); } function openSkillCreate() { if (typeof switchPanel === 'function' && _currentPanel !== 'skills') switchPanel('skills'); _skillPreFormDetail = _currentSkillDetail ? { ..._currentSkillDetail } : null; _editingSkillName = null; _skillMode = 'create'; _renderSkillForm({ name: '', category: '', content: '', isEdit: false }); } function _renderSkillForm({ name, category, content, isEdit }) { const title = $('skillDetailTitle'); const body = $('skillDetailBody'); const empty = $('skillDetailEmpty'); if (!body || !title) return; title.textContent = isEdit ? t('skills_edit') + ' · ' + name : t('new_skill'); const nameDisabled = isEdit ? 'disabled' : ''; const nameHint = isEdit ? `
${esc(t('skill_rename_not_supported') || 'Renaming a skill is not supported. Create a new skill and delete the old one to rename.')}
` : ''; body.innerHTML = `
${nameHint}
`; body.style.display = ''; if (empty) empty.style.display = 'none'; _setSkillHeaderButtons(isEdit ? 'edit' : 'create'); const focusEl = isEdit ? $('skillFormCategory') : $('skillFormName'); if (focusEl) focusEl.focus(); } function cancelSkillForm() { _editingSkillName = null; if (_skillPreFormDetail) { const snap = _skillPreFormDetail; _skillPreFormDetail = null; _currentSkillDetail = snap; _renderSkillDetail(snap.name, snap.content || '', snap.linked_files || {}); return; } // Revert to empty state _skillPreFormDetail = null; _currentSkillDetail = null; _skillMode = 'empty'; const body = $('skillDetailBody'); const empty = $('skillDetailEmpty'); const title = $('skillDetailTitle'); if (body) { body.innerHTML = ''; body.style.display = 'none'; } if (empty) empty.style.display = ''; if (title) title.textContent = ''; _setSkillHeaderButtons('empty'); } async function saveSkillForm() { const nameInput = $('skillFormName'); const catInput = $('skillFormCategory'); const contentInput = $('skillFormContent'); const errEl = $('skillFormError'); if (!nameInput || !contentInput || !errEl) return; const name = (nameInput.value || '').trim().toLowerCase().replace(/\s+/g, '-'); const category = (catInput ? (catInput.value || '').trim() : ''); const content = contentInput.value; errEl.style.display = 'none'; if (!name) { errEl.textContent = t('skill_name_required'); errEl.style.display = ''; return; } if (!content.trim()) { errEl.textContent = t('content_required'); errEl.style.display = ''; return; } try { await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})}); showToast(_editingSkillName ? t('skill_updated') : t('skill_created')); _skillsData = null; _cronSkillsCache = null; _editingSkillName = null; _skillPreFormDetail = null; await loadSkills(); // Reload the saved skill in read mode with fresh content const row = document.querySelector(`.skill-item .skill-name`); const match = document.querySelectorAll('.skill-item'); let targetEl = null; match.forEach(el => { const nm = el.querySelector('.skill-name'); if (nm && nm.textContent === name) targetEl = el; }); await openSkill(name, targetEl); } catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; } } // Back-compat aliases (delete flow + any old callers) const submitSkillSave = saveSkillForm; function toggleSkillForm(){ openSkillCreate(); } async function deleteCurrentSkill() { if (!_currentSkillDetail) return; const name = _currentSkillDetail.name; const message = t('skill_delete_confirm') ? t('skill_delete_confirm').replace('{0}', name) : `Delete skill "${name}"?`; const ok = await showConfirmDialog({ title: t('delete_title') || 'Delete', message, confirmLabel: t('delete_title') || 'Delete', danger: true, focusCancel: true, }); if (!ok) return; try { await api('/api/skills/delete', { method:'POST', body: JSON.stringify({ name }) }); _currentSkillDetail = null; _skillPreFormDetail = null; _skillsData = null; _cronSkillsCache = null; _skillMode = 'empty'; const body = $('skillDetailBody'); const empty = $('skillDetailEmpty'); const title = $('skillDetailTitle'); if (body) { body.innerHTML = ''; body.style.display = 'none'; } if (empty) empty.style.display = ''; if (title) title.textContent = ''; _setSkillHeaderButtons('empty'); await loadSkills(); showToast(t('skill_deleted') || 'Skill deleted'); } catch(e) { setStatus(t('error_prefix') + e.message); } } // ── Memory (main view) ── let _memoryData = null; let _currentMemorySection = null; // 'memory' | 'user' let _memoryMode = 'empty'; // 'empty' | 'read' | 'edit' const MEMORY_SECTIONS = [ { key: 'memory', labelKey: 'my_notes', emptyKey: 'no_notes_yet', iconKey: 'brain' }, { key: 'user', labelKey: 'user_profile', emptyKey: 'no_profile_yet', iconKey: 'user' }, ]; function _memorySectionMeta(key) { return MEMORY_SECTIONS.find(s => s.key === key) || MEMORY_SECTIONS[0]; } function _memorySectionContent(key) { if (!_memoryData) return ''; return key === 'user' ? (_memoryData.user || '') : (_memoryData.memory || ''); } function _memorySectionMtime(key) { if (!_memoryData) return 0; return key === 'user' ? (_memoryData.user_mtime || 0) : (_memoryData.memory_mtime || 0); } function _setMemoryHeaderButtons(mode) { const show = b => b && (b.style.display = ''); const hide = b => b && (b.style.display = 'none'); const editBtn = $('btnEditMemoryDetail'); const cancelBtn = $('btnCancelMemoryDetail'); const saveBtn = $('btnSaveMemoryDetail'); if (mode === 'read') { show(editBtn); hide(cancelBtn); hide(saveBtn); } else if (mode === 'edit') { hide(editBtn); show(cancelBtn); show(saveBtn); } else { hide(editBtn); hide(cancelBtn); hide(saveBtn); } } function _renderMemoryDetail(section) { const meta = _memorySectionMeta(section); const title = $('memoryDetailTitle'); const body = $('memoryDetailBody'); const empty = $('memoryDetailEmpty'); if (!title || !body) return; title.textContent = t(meta.labelKey); const content = _memorySectionContent(section); const mtime = _memorySectionMtime(section); const mtimeStr = mtime ? new Date(mtime * 1000).toLocaleString() : ''; const mtimeHtml = mtimeStr ? `
${esc(mtimeStr)}
` : ''; const inner = content ? `
${renderMd(content)}
` : `
${esc(t(meta.emptyKey))}
`; body.innerHTML = `
${mtimeHtml}${inner}
`; body.style.display = ''; if (empty) empty.style.display = 'none'; _memoryMode = 'read'; _setMemoryHeaderButtons('read'); } function _renderMemoryEdit(section) { const meta = _memorySectionMeta(section); const title = $('memoryDetailTitle'); const body = $('memoryDetailBody'); const empty = $('memoryDetailEmpty'); if (!title || !body) return; title.textContent = t(meta.labelKey); const content = _memorySectionContent(section); body.innerHTML = `
`; body.style.display = ''; if (empty) empty.style.display = 'none'; _memoryMode = 'edit'; _setMemoryHeaderButtons('edit'); const ta = $('memEditContent'); if (ta) ta.focus(); } function openMemorySection(section, el) { _currentMemorySection = section; document.querySelectorAll('#memoryPanel .side-menu-item').forEach(e => e.classList.remove('active')); if (el) el.classList.add('active'); _renderMemoryDetail(section); } function editCurrentMemory() { if (!_currentMemorySection) return; _renderMemoryEdit(_currentMemorySection); } function cancelMemoryEdit() { if (!_currentMemorySection) return; _renderMemoryDetail(_currentMemorySection); } // Legacy alias (kept for any stale references) function toggleMemoryEdit() { editCurrentMemory(); } function closeMemoryEdit() { cancelMemoryEdit(); } async function submitMemorySave() { if (!_currentMemorySection) return; const ta = $('memEditContent'); const errEl = $('memEditError'); if (!ta) return; if (errEl) errEl.style.display = 'none'; try { await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: _currentMemorySection, content: ta.value})}); showToast(t('memory_saved')); await loadMemory(true); _renderMemoryDetail(_currentMemorySection); } catch(e) { if (errEl) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; } } } // ── Workspace management ── let _workspaceList = []; // cached from /api/workspaces let _wsSuggestTimer = null; let _wsSuggestReq = 0; let _wsSuggestIndex = -1; function closeWorkspacePathSuggestions(){ const box=$('workspaceFormPathSuggestions'); if(box){ box.innerHTML=''; box.style.display='none'; } _wsSuggestIndex=-1; } function _applyWorkspaceSuggestion(path){ const input=$('workspaceFormPath'); const next=(path||'').endsWith('/')?(path||''):`${path||''}/`; if(input){ input.value=next; input.focus(); input.setSelectionRange(next.length, next.length); } scheduleWorkspacePathSuggestions(); } function _highlightWorkspaceSuggestion(idx){ const box=$('workspaceFormPathSuggestions'); if(!box)return; const items=[...box.querySelectorAll('.ws-suggest-item')]; items.forEach((el,i)=>{ const active=i===idx; el.classList.toggle('active', active); if(active) el.scrollIntoView({block:'nearest'}); }); } function _renderWorkspacePathSuggestions(paths){ const box=$('workspaceFormPathSuggestions'); if(!box)return; box.innerHTML=''; if(!paths || !paths.length){ box.style.display='none'; _wsSuggestIndex=-1; return; } paths.forEach((path, idx)=>{ const pathParts=(path||'').split('/').filter(Boolean); const leaf=pathParts[pathParts.length-1]||path; const parent=pathParts.length>1?`/${pathParts.slice(0,-1).join('/')}`:'/'; const item=document.createElement('button'); item.type='button'; item.className='ws-suggest-item'; item.innerHTML=`${esc(leaf)}${esc(parent)}`; item.dataset.path=path; item.onmouseenter=()=>{_wsSuggestIndex=idx;_highlightWorkspaceSuggestion(idx);}; item.onmousedown=(e)=>{e.preventDefault();_applyWorkspaceSuggestion(path);}; box.appendChild(item); }); box.style.display='block'; _wsSuggestIndex=0; _highlightWorkspaceSuggestion(_wsSuggestIndex); } async function _loadWorkspacePathSuggestions(prefix){ const reqId=++_wsSuggestReq; try{ const qs=new URLSearchParams({prefix:prefix||''}).toString(); const data=await api(`/api/workspaces/suggest?${qs}`); if(reqId!==_wsSuggestReq)return; _renderWorkspacePathSuggestions(data.suggestions||[]); }catch(_){ if(reqId!==_wsSuggestReq)return; closeWorkspacePathSuggestions(); } } function scheduleWorkspacePathSuggestions(){ const input=$('workspaceFormPath'); if(!input)return; const prefix=input.value.trim(); if(!prefix){ closeWorkspacePathSuggestions(); return; } if(_wsSuggestTimer) clearTimeout(_wsSuggestTimer); _wsSuggestTimer=setTimeout(()=>{ _loadWorkspacePathSuggestions(prefix); }, 120); } function getWorkspaceFriendlyName(path){ // Look up the friendly name from the workspace list cache, fallback to last path segment if(_workspaceList && _workspaceList.length){ const match=_workspaceList.find(w=>w.path===path); if(match && match.name) return match.name; } return path.split('/').filter(Boolean).pop()||path; } function syncWorkspaceDisplays(){ const hasSession=!!(S.session&&S.session.workspace); // Fall back to the profile default workspace when no session is active yet. // S._profileDefaultWorkspace is set during boot and profile switches from /api/settings. const defaultWs=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||''; const ws=hasSession?S.session.workspace:(defaultWs||''); const hasWorkspace=!!(ws); const label=hasWorkspace?getWorkspaceFriendlyName(ws):t('no_workspace'); const sidebarName=$('sidebarWsName'); const sidebarPath=$('sidebarWsPath'); if(sidebarName) sidebarName.textContent=label; if(sidebarPath) sidebarPath.textContent=ws; const composerChip=$('composerWorkspaceChip'); const composerLabel=$('composerWorkspaceLabel'); const composerDropdown=$('composerWsDropdown'); if(!hasWorkspace && composerDropdown) composerDropdown.classList.remove('open'); // Only show workspace label once boot has finished to prevent // flash of "No workspace" before the saved session finishes loading. if(composerLabel) composerLabel.textContent=S._bootReady?label:''; if(composerChip){ composerChip.disabled=!hasWorkspace; composerChip.title=hasWorkspace?ws:t('no_workspace'); composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open'))); } } async function loadWorkspaceList(){ try{ const data = await api('/api/workspaces'); _workspaceList = data.workspaces || []; syncWorkspaceDisplays(); return data; }catch(e){ return {workspaces:[], last:''}; } } function _renderWorkspaceAction(label, meta, iconSvg, onClick){ const opt=document.createElement('div'); opt.className='ws-opt ws-opt-action'; opt.innerHTML=`${iconSvg}${esc(label)}${meta?`${esc(meta)}`:''}`; opt.onclick=onClick; return opt; } function _positionComposerWsDropdown(){ const dd=$('composerWsDropdown'); const chip=$('composerWorkspaceGroup')||$('composerWorkspaceChip'); const footer=document.querySelector('.composer-footer'); if(!dd||!chip||!footer)return; const chipRect=chip.getBoundingClientRect(); const footerRect=footer.getBoundingClientRect(); let left=chipRect.left-footerRect.left; const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth); left=Math.max(0, Math.min(left, maxLeft)); dd.style.left=`${left}px`; } function _positionProfileDropdown(){ const dd=$('profileDropdown'); const chip=$('profileChip'); const footer=document.querySelector('.composer-footer'); if(!dd||!chip||!footer)return; const chipRect=chip.getBoundingClientRect(); const footerRect=footer.getBoundingClientRect(); let left=chipRect.left-footerRect.left; const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth); left=Math.max(0, Math.min(left, maxLeft)); dd.style.left=`${left}px`; } function renderWorkspaceDropdownInto(dd, workspaces, currentWs){ if(!dd)return; dd.innerHTML=''; for(const w of workspaces){ const opt=document.createElement('div'); opt.className='ws-opt'+(w.path===currentWs?' active':''); opt.innerHTML=`${esc(w.name)}${esc(w.path)}`; opt.onclick=()=>switchToWorkspace(w.path,w.name); dd.appendChild(opt); } dd.appendChild(document.createElement('div')).className='ws-divider'; dd.appendChild(_renderWorkspaceAction( t('workspace_choose_path'), t('workspace_choose_path_meta'), li('folder',12), ()=>promptWorkspacePath() )); const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div); dd.appendChild(_renderWorkspaceAction( t('workspace_manage'), t('workspace_manage_meta'), li('settings',12), ()=>{closeWsDropdown();mobileSwitchPanel('workspaces');} )); } function toggleWsDropdown(){ const dd=$('wsDropdown'); if(!dd)return; const open=dd.classList.contains('open'); if(open){closeWsDropdown();} else{ closeProfileDropdown(); // close profile dropdown if open loadWorkspaceList().then(data=>{ renderWorkspaceDropdownInto(dd, data.workspaces, S.session?S.session.workspace:''); dd.classList.add('open'); }); } } function toggleComposerWsDropdown(){ const dd=$('composerWsDropdown'); const chip=$('composerWorkspaceChip'); if(!dd||!chip||chip.disabled)return; const open=dd.classList.contains('open'); if(open){closeWsDropdown();} else{ closeProfileDropdown(); if(typeof closeModelDropdown==='function') closeModelDropdown(); loadWorkspaceList().then(data=>{ renderWorkspaceDropdownInto(dd, data.workspaces, S.session?S.session.workspace:''); dd.classList.add('open'); _positionComposerWsDropdown(); chip.classList.add('active'); }); } } function closeWsDropdown(){ const dd=$('wsDropdown'); const composerDd=$('composerWsDropdown'); const composerChip=$('composerWorkspaceChip'); if(dd)dd.classList.remove('open'); if(composerDd)composerDd.classList.remove('open'); if(composerChip)composerChip.classList.remove('active'); } document.addEventListener('click',e=>{ if( !e.target.closest('#composerWorkspaceChip') && !e.target.closest('#composerWsDropdown') ) closeWsDropdown(); }); window.addEventListener('resize',()=>{ const dd=$('composerWsDropdown'); if(dd&&dd.classList.contains('open')) _positionComposerWsDropdown(); }); async function loadWorkspacesPanel(){ const panel=$('workspacesPanel'); if(!panel)return; const data=await loadWorkspaceList(); renderWorkspacesPanel(data.workspaces); } function renderWorkspacesPanel(workspaces){ const panel=$('workspacesPanel'); panel.innerHTML=''; const activePath = S.session ? S.session.workspace : ''; for(let i=0;i${esc(t('profile_active'))}` : ''; row.innerHTML=` ${li('grip-vertical',12)}
${esc(w.name)}${activeBadge}
${esc(w.path)}
`; // Click on info area only — not on drag handle const info=row.querySelector('.ws-row-info'); if(info) info.onclick = (e) => { e.stopPropagation(); openWorkspaceDetail(w.path, row); }; if (_currentWorkspaceDetail && _currentWorkspaceDetail.path === w.path) row.classList.add('active'); // ── Drag-and-drop reorder ── row.addEventListener('dragstart', (e) => { // Only allow drag from the grip handle or the row itself row.classList.add('dragging'); e.dataTransfer.effectAllowed='move'; e.dataTransfer.setData('text/plain', w.path); // Required for Firefox drag ghost if(e.dataTransfer.setDragImage) e.dataTransfer.setDragImage(row, 0, 0); }); row.addEventListener('dragend', () => { row.classList.remove('dragging'); panel.querySelectorAll('.ws-row.drag-over').forEach(r => r.classList.remove('drag-over')); }); row.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect='move'; // Highlight drop target panel.querySelectorAll('.ws-row.drag-over').forEach(r => r.classList.remove('drag-over')); if(!row.classList.contains('dragging')) row.classList.add('drag-over'); }); row.addEventListener('dragleave', () => { row.classList.remove('drag-over'); }); row.addEventListener('drop', async (e) => { e.preventDefault(); row.classList.remove('drag-over'); const fromPath = e.dataTransfer.getData('text/plain'); const toPath = w.path; if(fromPath === toPath) return; // Same item, no-op // Compute new order const currentPaths = workspaces.map(ws => ws.path); const fromIdx = currentPaths.indexOf(fromPath); const toIdx = currentPaths.indexOf(toPath); if(fromIdx < 0 || toIdx < 0) return; currentPaths.splice(fromIdx, 1); currentPaths.splice(toIdx, 0, fromPath); try { const res = await api('/api/workspaces/reorder', { method: 'POST', body: JSON.stringify({ paths: currentPaths }) }); if(res && res.ok){ renderWorkspacesPanel(res.workspaces); // Also refresh sidebar dropdown loadWorkspaceList().then(() => {}); } } catch(err){ showToast(t('workspace_reorder_failed'), 'error'); } }); panel.appendChild(row); } const hint=document.createElement('div'); hint.style.cssText='font-size:11px;color:var(--muted);padding:8px 0'; hint.textContent=t('workspace_paths_validated_hint'); panel.appendChild(hint); // Re-render detail if we have one cached and we're not in a form if (_currentWorkspaceDetail && _workspaceMode !== 'create' && _workspaceMode !== 'edit') { const refreshed = workspaces.find(w => w.path === _currentWorkspaceDetail.path); if (refreshed) _renderWorkspaceDetail(refreshed); else _clearWorkspaceDetail(); } } function _renderWorkspaceDetail(ws){ _currentWorkspaceDetail = ws; const title = $('workspaceDetailTitle'); const body = $('workspaceDetailBody'); const empty = $('workspaceDetailEmpty'); if (!title || !body) return; title.textContent = ws.name || ws.path; const activePath = S.session ? S.session.workspace : ''; const isActive = ws.path === activePath; const isDefault = !!ws.is_default; const statusBadge = isActive ? `${esc(t('profile_active'))}` : `Inactive`; const defaultBadge = isDefault ? ` ${esc(t('profile_default_label'))}` : ''; body.innerHTML = `
Space
Name
${esc(ws.name || '')}
Path
${esc(ws.path)}
Status
${statusBadge}${defaultBadge}
`; body.style.display = ''; if (empty) empty.style.display = 'none'; _workspaceMode = 'read'; _setWorkspaceHeaderButtons('read', ws); } function _setWorkspaceHeaderButtons(mode, ws){ const actBtn = $('btnActivateWorkspaceDetail'); const editBtn = $('btnEditWorkspaceDetail'); const delBtn = $('btnDeleteWorkspaceDetail'); const cancelBtn = $('btnCancelWorkspaceDetail'); const saveBtn = $('btnSaveWorkspaceDetail'); const show = b => b && (b.style.display = ''); const hide = b => b && (b.style.display = 'none'); if (mode === 'read') { const activePath = S.session ? S.session.workspace : ''; const isActive = ws && ws.path === activePath; const isDefault = !!(ws && ws.is_default); if (isActive) hide(actBtn); else show(actBtn); show(editBtn); if (isDefault) hide(delBtn); else show(delBtn); hide(cancelBtn); hide(saveBtn); } else if (mode === 'create' || mode === 'edit') { hide(actBtn); hide(editBtn); hide(delBtn); show(cancelBtn); show(saveBtn); } else { [actBtn, editBtn, delBtn, cancelBtn, saveBtn].forEach(hide); } } function openWorkspaceDetail(path, el){ if (!_workspaceList) return; const ws = _workspaceList.find(w => w.path === path); if (!ws) return; document.querySelectorAll('.ws-row').forEach(e => e.classList.remove('active')); const target = el || document.querySelector(`.ws-row[data-path="${CSS.escape(path)}"]`); if (target) target.classList.add('active'); _workspacePreFormDetail = null; _renderWorkspaceDetail(ws); } function _clearWorkspaceDetail(){ _currentWorkspaceDetail = null; _workspaceMode = 'empty'; const title = $('workspaceDetailTitle'); const body = $('workspaceDetailBody'); const empty = $('workspaceDetailEmpty'); if (title) title.textContent = ''; if (body) { body.innerHTML = ''; body.style.display = 'none'; } if (empty) empty.style.display = ''; _setWorkspaceHeaderButtons('empty'); } async function activateCurrentWorkspace(){ if (!_currentWorkspaceDetail) return; await switchToWorkspace(_currentWorkspaceDetail.path, _currentWorkspaceDetail.name); // Re-render detail after activation so the active badge updates _renderWorkspaceDetail(_currentWorkspaceDetail); } async function deleteCurrentWorkspace(){ if (!_currentWorkspaceDetail) return; const path = _currentWorkspaceDetail.path; const _ok = await showConfirmDialog({title:t('workspace_remove_confirm_title'),message:t('workspace_remove_confirm_message',path),confirmLabel:t('remove'),danger:true,focusCancel:true}); if(!_ok) return; try{ const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})}); _workspaceList=data.workspaces; _clearWorkspaceDetail(); renderWorkspacesPanel(data.workspaces); showToast(t('workspace_removed')); }catch(e){setStatus(t('remove_failed')+e.message);} } function openWorkspaceCreate(){ if (typeof switchPanel === 'function' && _currentPanel !== 'workspaces') switchPanel('workspaces'); _workspacePreFormDetail = _currentWorkspaceDetail ? { ..._currentWorkspaceDetail } : null; _workspaceMode = 'create'; _renderWorkspaceForm({ name:'', path:'', isEdit:false }); } function editCurrentWorkspace(){ if (!_currentWorkspaceDetail) return; _workspacePreFormDetail = { ..._currentWorkspaceDetail }; _workspaceMode = 'edit'; _renderWorkspaceForm({ name: _currentWorkspaceDetail.name || '', path: _currentWorkspaceDetail.path || '', isEdit: true }); } function _renderWorkspaceForm({ name, path, isEdit }){ const title = $('workspaceDetailTitle'); const body = $('workspaceDetailBody'); const empty = $('workspaceDetailEmpty'); if (!title || !body) return; title.textContent = isEdit ? (t('edit') + ' · ' + (name || path)) : (t('workspace_new_title') || 'New space'); const pathDisabled = isEdit ? 'disabled' : ''; const pathHint = isEdit ? `
${esc(t('workspace_path_readonly') || 'Path cannot be changed. Rename only.')}
` : `
${esc(t('workspace_paths_validated_hint'))}
`; body.innerHTML = `
${pathHint}
`; body.style.display = ''; if (empty) empty.style.display = 'none'; _setWorkspaceHeaderButtons(isEdit ? 'edit' : 'create'); if (!isEdit) _wireWorkspaceFormPathSuggestions(); const focus = isEdit ? $('workspaceFormName') : $('workspaceFormPath'); if (focus) focus.focus(); } function cancelWorkspaceForm(){ closeWorkspacePathSuggestions(); if (_workspacePreFormDetail) { const snap = _workspacePreFormDetail; _workspacePreFormDetail = null; _renderWorkspaceDetail(snap); return; } _clearWorkspaceDetail(); } async function saveWorkspaceForm(){ const nameEl = $('workspaceFormName'); const pathEl = $('workspaceFormPath'); const errEl = $('workspaceFormError'); if (!pathEl || !errEl) return; const name = (nameEl ? nameEl.value : '').trim(); const path = (pathEl.value || '').trim(); errEl.style.display = 'none'; if (!path) { errEl.textContent = t('workspace_path_required') || 'Path is required'; errEl.style.display = ''; return; } try { if (_workspaceMode === 'edit' && _currentWorkspaceDetail) { const targetPath = _currentWorkspaceDetail.path; const newName = name || _currentWorkspaceDetail.name || ''; await api('/api/workspaces/rename', { method:'POST', body: JSON.stringify({ path: targetPath, name: newName }) }); // Refresh list and re-render detail const data = await api('/api/workspaces'); _workspaceList = data.workspaces || []; _workspacePreFormDetail = null; showToast(t('workspace_renamed') || t('workspace_added')); renderWorkspacesPanel(_workspaceList); openWorkspaceDetail(targetPath); return; } const data = await api('/api/workspaces/add', { method:'POST', body: JSON.stringify({ path }) }); _workspaceList = data.workspaces || []; _workspacePreFormDetail = null; // Apply rename if a friendly name was supplied if (name) { try { await api('/api/workspaces/rename', { method:'POST', body: JSON.stringify({ path, name }) }); } catch(_) {} const refreshed = await api('/api/workspaces'); _workspaceList = refreshed.workspaces || _workspaceList; } renderWorkspacesPanel(_workspaceList); showToast(t('workspace_added')); const added = _workspaceList.find(w => w.path === path) || _workspaceList[_workspaceList.length - 1]; if (added) openWorkspaceDetail(added.path); } catch (e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; } } // Back-compat: any legacy caller of addWorkspace() opens the new form instead. function addWorkspace(){ openWorkspaceCreate(); } function _wireWorkspaceFormPathSuggestions(){ const input=$('workspaceFormPath'); if(!input) return; input.oninput=()=>scheduleWorkspacePathSuggestions(); input.onfocus=()=>{ if(input.value.trim()) scheduleWorkspacePathSuggestions(); else closeWorkspacePathSuggestions(); }; input.onkeydown=(e)=>{ const box=$('workspaceFormPathSuggestions'); const items=box?[...box.querySelectorAll('.ws-suggest-item')]:[]; if(!items.length){ return; } if(e.key==='ArrowDown'){ e.preventDefault(); _wsSuggestIndex=Math.min(items.length-1,Math.max(-1,_wsSuggestIndex)+1); _highlightWorkspaceSuggestion(_wsSuggestIndex); return; } if(e.key==='ArrowUp'){ e.preventDefault(); _wsSuggestIndex=_wsSuggestIndex<=0?0:_wsSuggestIndex-1; _highlightWorkspaceSuggestion(_wsSuggestIndex); return; } if(e.key==='Escape'){ e.preventDefault(); closeWorkspacePathSuggestions(); return; } if(e.key==='Enter' && _wsSuggestIndex>=0 && items[_wsSuggestIndex]){ e.preventDefault(); _applyWorkspaceSuggestion(items[_wsSuggestIndex].dataset.path||''); return; } if(e.key==='Tab' && _wsSuggestIndex>=0 && items[_wsSuggestIndex]){ e.preventDefault(); _applyWorkspaceSuggestion(items[_wsSuggestIndex].dataset.path||''); return; } }; } document.addEventListener('click',e=>{ if(!e.target.closest('.workspace-form-path-wrap')) closeWorkspacePathSuggestions(); }); async function removeWorkspace(path){ const _rmWs=await showConfirmDialog({title:t('workspace_remove_confirm_title'),message:t('workspace_remove_confirm_message',path),confirmLabel:t('remove'),danger:true,focusCancel:true}); if(!_rmWs) return; try{ const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})}); _workspaceList=data.workspaces; renderWorkspacesPanel(data.workspaces); showToast(t('workspace_removed')); }catch(e){setStatus(t('remove_failed')+e.message);} } async function promptWorkspacePath(){ // Opus review Q6: if called from blank page (no session), auto-create one first. if(!S.session){ const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||''; if(!ws)return; try{ const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})}); if(r&&r.session){S.session=r.session;S.messages=[];if(typeof syncTopbar==='function')syncTopbar();if(typeof renderMessages==='function')renderMessages();if(typeof renderSessionList==='function')await renderSessionList();} }catch(e){showToast(t('workspace_switch_failed')+e.message);return;} if(!S.session)return; } const value=await showPromptDialog({ title:t('workspace_switch_prompt_title'), message:t('workspace_switch_prompt_message'), confirmLabel:t('workspace_switch_prompt_confirm'), placeholder:t('workspace_switch_prompt_placeholder'), value:S.session.workspace||'' }); const path=(value||'').trim(); if(!path)return; try{ const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})}); _workspaceList=data.workspaces||[]; const target=_workspaceList[_workspaceList.length-1]; if(!target) throw new Error(t('workspace_not_added')); await switchToWorkspace(target.path,target.name); }catch(e){ if(String(e.message||'').includes('Workspace already in list')){ showToast(t('workspace_already_saved')); return; } showToast(t('workspace_switch_failed')+e.message); } } async function switchToWorkspace(path,name){ // Opus review Q6: if called from blank page, auto-create a session bound to // the requested workspace so the switch doesn't silently no-op. if(!S.session){ const ws=path||(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||''; if(!ws){showToast(t('no_workspace'));return;} try{ const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})}); if(r&&r.session){S.session=r.session;S.messages=[];if(typeof syncTopbar==='function')syncTopbar();if(typeof renderMessages==='function')renderMessages();if(typeof renderSessionList==='function')await renderSessionList();} }catch(e){if(typeof setStatus==='function')setStatus(t('switch_failed')+e.message);return;} if(!S.session)return; } if(S.busy){ showToast(t('workspace_busy_switch')); return; } if(typeof _previewDirty!=='undefined'&&_previewDirty){ const discard=await showConfirmDialog({ title:t('discard_file_edits_title'), message:t('discard_file_edits_message'), confirmLabel:t('discard'), danger:true }); if(!discard)return; if(typeof cancelEditMode==='function')cancelEditMode(); if(typeof clearPreview==='function')clearPreview(); } try{ closeWsDropdown(); await api('/api/session/update',{method:'POST',body:JSON.stringify({ session_id:S.session.session_id, workspace:path, model:S.session.model })}); S.session.workspace=path; // Explicit workspace switch = user overriding any pending profile-switch default. // Clear the one-shot flag so a subsequent newSession() inherits this choice instead. S._profileSwitchWorkspace=null; syncTopbar(); await loadDir('.'); showToast(t('workspace_switched_to',name||getWorkspaceFriendlyName(path))); }catch(e){setStatus(t('switch_failed')+e.message);} } // ── Profile panel + dropdown ── let _profilesCache = null; async function loadProfilesPanel() { const panel = $('profilesPanel'); if (!panel) return; try { const data = await api('/api/profiles'); _profilesCache = data; panel.innerHTML = ''; if (!data.profiles || !data.profiles.length) { panel.innerHTML = `
${esc(t('profiles_no_profiles'))}
`; if (_profileMode !== 'create') _clearProfileDetail(); return; } const activeName = (S.activeProfile && data.profiles.some(p => p.name === S.activeProfile)) ? S.activeProfile : (data.active || 'default'); for (const p of data.profiles) { const card = document.createElement('div'); card.className = 'profile-card'; card.dataset.name = p.name; const meta = []; if (p.model) meta.push(p.model.split('/').pop()); if (p.provider) meta.push(p.provider); if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count)); const gwDot = p.gateway_running ? `` : ``; const isActive = p.name === activeName; const activeBadge = isActive ? `${esc(t('profile_active'))}` : ''; const defaultBadge = p.is_default ? ` ${esc(t('profile_default_label'))}` : ''; card.innerHTML = `
${gwDot}${esc(p.name)}${defaultBadge}${activeBadge}
${meta.length ? `
${esc(meta.join(' \u00b7 '))}
` : `
${esc(t('profile_no_configuration'))}
`}
`; card.onclick = () => openProfileDetail(p.name, card); if (_currentProfileDetail && _currentProfileDetail.name === p.name) card.classList.add('active'); panel.appendChild(card); } // Re-render detail with fresh data if we have one and we're not in a form if (_currentProfileDetail && _profileMode !== 'create') { const refreshed = data.profiles.find(p => p.name === _currentProfileDetail.name); if (refreshed) _renderProfileDetail(refreshed, data.active); else _clearProfileDetail(); } } catch (e) { panel.innerHTML = `
${esc(t('error_prefix'))}${esc(e.message)}
`; } } function _renderProfileDetail(p, activeName){ _currentProfileDetail = p; const title = $('profileDetailTitle'); const body = $('profileDetailBody'); const empty = $('profileDetailEmpty'); if (!title || !body) return; title.textContent = p.name; const isActive = p.name === activeName; const isDefault = !!p.is_default; const statusBadge = isActive ? `${esc(t('profile_active'))}` : `Inactive`; const defaultBadge = isDefault ? ` ${esc(t('profile_default_label'))}` : ''; const gwBadge = p.gateway_running ? `${esc(t('profile_gateway_running'))}` : `${esc(t('profile_gateway_stopped'))}`; const rows = []; rows.push(`
Status
${statusBadge}${defaultBadge}
`); rows.push(`
Gateway
${gwBadge}
`); if (p.model) rows.push(`
Model
${esc(p.model)}
`); if (p.provider) rows.push(`
Provider
${esc(p.provider)}
`); if (p.base_url) rows.push(`
Base URL
${esc(p.base_url)}
`); rows.push(`
API key
${p.has_env ? esc(t('profile_api_keys_configured')) : 'Not configured'}
`); if (typeof p.skill_count === 'number') rows.push(`
Skills
${esc(t('profile_skill_count', p.skill_count))}
`); if (p.default_workspace) rows.push(`
Default space
${esc(p.default_workspace)}
`); body.innerHTML = `
Profile
${rows.join('')}
`; body.style.display = ''; if (empty) empty.style.display = 'none'; _profileMode = 'read'; _setProfileHeaderButtons('read', p, activeName); } function _setProfileHeaderButtons(mode, p, activeName){ const actBtn = $('btnActivateProfileDetail'); const delBtn = $('btnDeleteProfileDetail'); const cancelBtn = $('btnCancelProfileDetail'); const saveBtn = $('btnSaveProfileDetail'); const show = b => b && (b.style.display = ''); const hide = b => b && (b.style.display = 'none'); if (mode === 'read') { const isActive = p && p.name === activeName; const isDefault = !!(p && p.is_default); if (isActive) hide(actBtn); else show(actBtn); if (isDefault) hide(delBtn); else show(delBtn); hide(cancelBtn); hide(saveBtn); } else if (mode === 'create') { hide(actBtn); hide(delBtn); show(cancelBtn); show(saveBtn); } else { [actBtn, delBtn, cancelBtn, saveBtn].forEach(hide); } } function openProfileDetail(name, el){ if (!_profilesCache || !_profilesCache.profiles) return; const p = _profilesCache.profiles.find(x => x.name === name); if (!p) return; document.querySelectorAll('.profile-card').forEach(e => e.classList.remove('active')); const target = el || document.querySelector(`.profile-card[data-name="${CSS.escape(name)}"]`); if (target) target.classList.add('active'); _profilePreFormDetail = null; _renderProfileDetail(p, _profilesCache.active); } function _clearProfileDetail(){ _currentProfileDetail = null; _profileMode = 'empty'; const title = $('profileDetailTitle'); const body = $('profileDetailBody'); const empty = $('profileDetailEmpty'); if (title) title.textContent = ''; if (body) { body.innerHTML = ''; body.style.display = 'none'; } if (empty) empty.style.display = ''; _setProfileHeaderButtons('empty'); } async function activateCurrentProfile(){ if (!_currentProfileDetail) return; await switchToProfile(_currentProfileDetail.name); } async function deleteCurrentProfile(){ if (!_currentProfileDetail) return; const name = _currentProfileDetail.name; const _ok = await showConfirmDialog({title:t('profile_delete_confirm_title',name),message:t('profile_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true}); if(!_ok) return; try { await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) }); _clearProfileDetail(); await loadProfilesPanel(); showToast(t('profile_deleted', name)); } catch (e) { showToast(t('delete_failed') + e.message); } } function renderProfileDropdown(data) { const dd = $('profileDropdown'); if (!dd) return; dd.innerHTML = ''; const profiles = data.profiles || []; const active = (S.activeProfile && profiles.some(p => p.name === S.activeProfile)) ? S.activeProfile : (data.active || 'default'); for (const p of profiles) { const opt = document.createElement('div'); opt.className = 'profile-opt' + (p.name === active ? ' active' : ''); const meta = []; if (p.model) meta.push(p.model.split('/').pop()); if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count)); const gwDot = ``; const checkmark = p.name === active ? ' ' : ''; const defaultBadge = p.is_default ? ` ${esc(t('profile_default_label'))}` : ''; opt.innerHTML = `
${gwDot}${esc(p.name)}${defaultBadge}${checkmark}
` + (meta.length ? `
${esc(meta.join(' \u00b7 '))}
` : ''); opt.onclick = async () => { closeProfileDropdown(); if (p.name === active) return; await switchToProfile(p.name); }; dd.appendChild(opt); } // Divider + Manage link const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div); const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage'; mgmt.innerHTML = `${li('settings',12)} ${esc(t('manage_profiles'))}`; mgmt.onclick = () => { closeProfileDropdown(); mobileSwitchPanel('profiles'); }; dd.appendChild(mgmt); } function toggleProfileDropdown() { const dd = $('profileDropdown'); if (!dd) return; if (dd.classList.contains('open')) { closeProfileDropdown(); return; } closeWsDropdown(); // close workspace dropdown if open if(typeof closeModelDropdown==='function') closeModelDropdown(); api('/api/profiles').then(data => { renderProfileDropdown(data); dd.classList.add('open'); _positionProfileDropdown(); const chip=$('profileChip'); if(chip) chip.classList.add('active'); }).catch(e => { showToast(t('profiles_load_failed')); }); } function closeProfileDropdown() { const dd = $('profileDropdown'); if (dd) dd.classList.remove('open'); const chip=$('profileChip'); if(chip) chip.classList.remove('active'); } document.addEventListener('click', e => { if (!e.target.closest('#profileChipWrap') && !e.target.closest('#profileDropdown')) closeProfileDropdown(); }); window.addEventListener('resize',()=>{ const dd=$('profileDropdown'); if(dd&&dd.classList.contains('open')) _positionProfileDropdown(); }); async function switchToProfile(name) { if (S.busy) { showToast(t('profiles_busy_switch')); return; } // ── Loading indicator ─────────────────────────────────────────────────── // Show spinner on the profile chip immediately so the user gets visual // feedback while the async switch is in progress. const _chip = $('profileChip'); const _chipLabel = $('profileChipLabel'); const _prevProfileName = S.activeProfile || 'default'; if (_chip) { _chip.classList.add('switching'); _chip.disabled = true; } // Optimistic name update — shows the target name right away if (_chipLabel) _chipLabel.textContent = name; // Determine whether the current session has any messages. // A session with messages is "in progress" and belongs to the current profile — // we must not retag it. We'll start a fresh session for the new profile instead. const sessionInProgress = S.session && S.messages && S.messages.length > 0; try { const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) }); S.activeProfile = data.active || name; // ── Model + Workspace (parallelized) ─────────────────────────────────── // populateModelDropdown hits /api/models; loadWorkspaceList hits /api/workspaces. // They are fully independent — run both simultaneously to cut switch time ~50%. localStorage.removeItem('hermes-webui-model'); _skillsData = null; _workspaceList = null; await Promise.all([populateModelDropdown(), loadWorkspaceList()]); // ── Apply model ──────────────────────────────────────────────────────── if (data.default_model) { const sel = $('modelSelect'); const resolved = _applyModelToDropdown(data.default_model, sel, window._activeProvider||null); const modelToUse = resolved || data.default_model; S._pendingProfileModel = modelToUse; // Only patch the in-memory session model if we're NOT about to replace the session if (S.session && !sessionInProgress) { S.session.model = modelToUse; } } // ── Apply workspace ──────────────────────────────────────────────────── if (data.default_workspace) { // Always store the persistent profile default — used for blank-page display // and workspace auto-bind throughout the session lifecycle (#804, #823). S._profileDefaultWorkspace = data.default_workspace; // Also set the one-shot flag consumed by newSession() so the first new // session after a profile switch inherits this workspace (#424). S._profileSwitchWorkspace = data.default_workspace; if (S.session && !sessionInProgress) { // Empty session (no messages yet) — safe to update it in place try { await api('/api/session/update', { method: 'POST', body: JSON.stringify({ session_id: S.session.session_id, workspace: data.default_workspace, model: S.session.model, })}); S.session.workspace = data.default_workspace; } catch (_) {} } } // ── Session ──────────────────────────────────────────────────────────── _showAllProfiles = false; if (sessionInProgress) { // The current session has messages and belongs to the previous profile. // Start a new session for the new profile so nothing gets cross-tagged. await newSession(false); // Apply profile default workspace to the newly created session (fixes #424) if (S._profileDefaultWorkspace && S.session) { try { await api('/api/session/update', { method: 'POST', body: JSON.stringify({ session_id: S.session.session_id, workspace: S._profileDefaultWorkspace, model: S.session.model, })}); S.session.workspace = S._profileDefaultWorkspace; } catch (_) {} } // Keep topbar chips (workspace/profile) in sync after creating the // new profile-scoped session. syncTopbar(); await renderSessionList(); showToast(t('profile_switched_new_conversation', name)); } else { // No messages yet — just refresh the list and topbar in place await renderSessionList(); syncTopbar(); // Refresh workspace file tree so the right panel shows the new // profile's workspace, not the previous one (#1214). if (S.session && S.session.workspace) loadDir('.'); showToast(t('profile_switched', name)); } // ── Sidebar panels ───────────────────────────────────────────────────── if (_currentPanel === 'skills') await loadSkills(); if (_currentPanel === 'memory') await loadMemory(); if (_currentPanel === 'tasks') await loadCrons(); if (_currentPanel === 'profiles') await loadProfilesPanel(); if (_currentPanel === 'workspaces') await loadWorkspacesPanel(); // Update composer placeholder and title bar to reflect profile name if (typeof applyBotName === 'function') applyBotName(); } catch (e) { // Revert the optimistic name update on error if (_chipLabel) _chipLabel.textContent = _prevProfileName; showToast(t('switch_failed') + e.message); } finally { // Always remove loading indicator regardless of success or failure if (_chip) { _chip.classList.remove('switching'); _chip.disabled = false; } } } function openProfileCreate(){ if (typeof switchPanel === 'function' && _currentPanel !== 'profiles') switchPanel('profiles'); _profilePreFormDetail = _currentProfileDetail ? { ..._currentProfileDetail } : null; _profileMode = 'create'; _renderProfileForm(); } function _renderProfileForm(){ const title = $('profileDetailTitle'); const body = $('profileDetailBody'); const empty = $('profileDetailEmpty'); if (!title || !body) return; title.textContent = t('new_profile'); body.innerHTML = `
${esc(t('profile_name_rule') || 'Lowercase letters, numbers, hyphens, underscores only.')}
`; body.style.display = ''; if (empty) empty.style.display = 'none'; _setProfileHeaderButtons('create'); const n = $('profileFormName'); if (n) n.focus(); } function cancelProfileForm(){ if (_profilePreFormDetail) { const snap = _profilePreFormDetail; _profilePreFormDetail = null; const activeName = _profilesCache ? _profilesCache.active : null; _renderProfileDetail(snap, activeName); return; } _clearProfileDetail(); } async function saveProfileForm(){ const nameEl = $('profileFormName'); const cloneEl = $('profileFormClone'); const baseEl = $('profileFormBaseUrl'); const apiKeyEl = $('profileFormApiKey'); const errEl = $('profileFormError'); if (!nameEl || !errEl) return; const name = (nameEl.value || '').trim().toLowerCase(); const cloneConfig = !!(cloneEl && cloneEl.checked); errEl.style.display = 'none'; if (!name) { errEl.textContent = t('name_required'); errEl.style.display = ''; return; } if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) { errEl.textContent = t('profile_name_rule'); errEl.style.display = ''; return; } const baseUrl = (baseEl ? (baseEl.value || '') : '').trim(); const apiKey = (apiKeyEl ? (apiKeyEl.value || '') : '').trim(); if (baseUrl && !/^https?:\/\//.test(baseUrl)) { errEl.textContent = t('profile_base_url_rule'); errEl.style.display = ''; return; } try { const payload = { name, clone_config: cloneConfig }; if (baseUrl) payload.base_url = baseUrl; if (apiKey) payload.api_key = apiKey; await api('/api/profile/create', { method: 'POST', body: JSON.stringify(payload) }); _profilePreFormDetail = null; await loadProfilesPanel(); showToast(t('profile_created', name)); openProfileDetail(name); } catch (e) { errEl.textContent = e.message || t('create_failed'); errEl.style.display = ''; } } // Back-compat const submitProfileCreate = saveProfileForm; function toggleProfileForm(){ openProfileCreate(); } async function deleteProfile(name) { const _delProf=await showConfirmDialog({title:t('profile_delete_confirm_title',name),message:t('profile_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true}); if(!_delProf) return; try { await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) }); await loadProfilesPanel(); showToast(t('profile_deleted', name)); } catch (e) { showToast(t('delete_failed') + e.message); } } // ── Memory panel ── async function loadMemory(force) { const panel = $('memoryPanel'); try { const data = await api('/api/memory'); _memoryData = data; if (panel) { panel.innerHTML = ''; for (const s of MEMORY_SECTIONS) { const el = document.createElement('button'); el.type = 'button'; el.className = 'side-menu-item'; if (_currentMemorySection === s.key) el.classList.add('active'); el.innerHTML = `${li(s.iconKey,16)}${esc(t(s.labelKey))}`; el.onclick = () => openMemorySection(s.key, el); panel.appendChild(el); } } if (_currentMemorySection && _memoryMode !== 'edit') { _renderMemoryDetail(_currentMemorySection); } } catch(e) { if (panel) panel.innerHTML = `
${esc(t('error_prefix'))}${esc(e.message)}
`; } } // Drag and drop const wrap=$('composerWrap');let dragCounter=0; document.addEventListener('dragover',e=>e.preventDefault()); document.addEventListener('dragenter',e=>{e.preventDefault();if(e.dataTransfer.types.includes('Files')||e.dataTransfer.types.includes('application/ws-path')){dragCounter++;wrap.classList.add('drag-over');}}); document.addEventListener('dragleave',e=>{dragCounter--;if(dragCounter<=0){dragCounter=0;wrap.classList.remove('drag-over');}}); document.addEventListener('drop',e=>{ e.preventDefault();dragCounter=0;wrap.classList.remove('drag-over'); // Workspace file/folder drag → insert @path reference into composer const wsPath=e.dataTransfer.getData('application/ws-path'); if(wsPath){ const msgEl=$('msg'); if(msgEl){ const start=msgEl.selectionStart;const end=msgEl.selectionEnd; const val=msgEl.value; const prefix=start>0&&!val[start-1].match(/\s/)?' ':''; const insert=prefix+'@'+wsPath+' '; msgEl.value=val.slice(0,start)+insert+val.slice(end); msgEl.selectionStart=msgEl.selectionEnd=start+insert.length; msgEl.focus(); } return; } // OS file drag → attach files const files=Array.from(e.dataTransfer.files); if(files.length){addFiles(files);$('msg').focus();} }); // ── Settings panel ─────────────────────────────────────────────────────────── let _settingsDirty = false; let _settingsThemeOnOpen = null; // track theme at open time for discard revert let _settingsSkinOnOpen = null; // track skin at open time for discard revert let _settingsFontSizeOnOpen = null; // track font size at open time for discard revert let _settingsHermesDefaultModelOnOpen = ''; let _settingsSection = 'conversation'; let _currentSettingsSection = 'conversation'; let _settingsAppearanceAutosaveTimer = null; let _settingsAppearanceAutosaveRetryPayload = null; function switchSettingsSection(name){ const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='system')?name:'conversation'; _settingsSection=section; _currentSettingsSection=section; const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',providers:'Providers',system:'System'}; // Sidebar menu items document.querySelectorAll('#settingsMenu .side-menu-item').forEach(it=>{ it.classList.toggle('active', it.dataset.settingsSection===section); }); // Panes in main ['conversation','appearance','preferences','providers','system'].forEach(key=>{ const pane=$('settingsPane'+map[key]); if(pane) pane.classList.toggle('active', key===section); }); // Sync mobile dropdown const dd=$('settingsSectionDropdown'); if(dd && dd.value!==section) dd.value=section; // Lazy-load providers when the tab is opened if(section==='providers') loadProvidersPanel(); } function _syncHermesPanelSessionActions(){ const hasSession=!!S.session; const visibleMessages=hasSession?(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length:0; const title=hasSession?(S.session.title||t('untitled')):t('active_conversation_none'); const meta=$('hermesSessionMeta'); if(meta){ meta.textContent=hasSession ? t('active_conversation_meta', title, visibleMessages) : t('active_conversation_none'); } const setDisabled=(id,disabled)=>{ const el=$(id); if(!el)return; el.disabled=!!disabled; el.classList.toggle('disabled',!!disabled); }; setDisabled('btnDownload',!hasSession||visibleMessages===0); setDisabled('btnExportJSON',!hasSession); setDisabled('btnClearConvModal',!hasSession||visibleMessages===0); } // Thin wrapper: settings now live in the main content area. External callers // (keyboard shortcuts, commands) keep working through this name. function toggleSettings(){ if(_currentPanel==='settings'){ _closeSettingsPanel(); } else { switchPanel('settings'); } } function _resetSettingsPanelState(){ const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none'; _setAppearanceAutosaveStatus(''); } function _hideSettingsPanel(){ _resetSettingsPanelState(); const target = _consumeSettingsTargetPanel('chat'); if(_currentPanel==='settings') switchPanel(target, {bypassSettingsGuard:true}); } // Close with unsaved-changes check. If dirty, show a confirm dialog. function _closeSettingsPanel(){ if(!_settingsDirty){ _revertSettingsPreview(); _hideSettingsPanel(); return; } _pendingSettingsTargetPanel = _pendingSettingsTargetPanel || 'chat'; _showSettingsUnsavedBar(); } // Revert live DOM/localStorage to what they were when the panel opened function _revertSettingsPreview(){ // Appearance controls autosave immediately. Closing/discarding the settings // panel must not roll back theme, skin, or font-size after the user sees the // inline saved state. } // Show the "Unsaved changes" bar inside the settings panel function _showSettingsUnsavedBar(){ let bar = $('settingsUnsavedBar'); if(bar){ bar.style.display=''; return; } // Create it bar = document.createElement('div'); bar.id = 'settingsUnsavedBar'; bar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.3);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:13px;'; bar.innerHTML = `${esc(t('settings_unsaved_changes'))}` + '' + `` + `` + ''; const body = document.querySelector('#mainSettings .settings-main') || document.querySelector('.settings-main'); if(body) body.prepend(bar); } function _discardSettings(){ _revertSettingsPreview(); _settingsDirty = false; _hideSettingsPanel(); } // Mark settings as dirty whenever anything changes function _markSettingsDirty(){ _settingsDirty = true; } // Apply TTS enabled state: show/hide TTS buttons on all assistant messages function _applyTtsEnabled(enabled){ document.querySelectorAll('.msg-tts-btn').forEach(btn=>{ btn.style.display=enabled?'':'none'; }); } function _appearancePayloadFromUi(){ return { theme: ($('settingsTheme')||{}).value || localStorage.getItem('hermes-theme') || 'dark', skin: ($('settingsSkin')||{}).value || localStorage.getItem('hermes-skin') || 'default', font_size: ($('settingsFontSize')||{}).value || localStorage.getItem('hermes-font-size') || 'default', }; } function _setAppearanceAutosaveStatus(state){ const el=$('settingsAppearanceAutosaveStatus'); if(!el) return; el.className='settings-autosave-status'; if(!state){ el.textContent=''; return; } el.classList.add('is-'+state); if(state==='saving'){ el.textContent=t('settings_autosave_saving'); }else if(state==='saved'){ el.textContent=t('settings_autosave_saved'); }else if(state==='failed'){ el.innerHTML=`${esc(t('settings_autosave_failed'))} `; } } function _rememberAppearanceSaved(payload){ if(!payload) return; _settingsThemeOnOpen=payload.theme||localStorage.getItem('hermes-theme')||'dark'; _settingsSkinOnOpen=payload.skin||localStorage.getItem('hermes-skin')||'default'; _settingsFontSizeOnOpen=payload.font_size||localStorage.getItem('hermes-font-size')||'default'; } function _scheduleAppearanceAutosave(){ const payload=_appearancePayloadFromUi(); // Keep discard/close behavior aligned with the new mental model: appearance // changes are committed immediately instead of treated as preview-only edits. _rememberAppearanceSaved(payload); _settingsAppearanceAutosaveRetryPayload=payload; _setAppearanceAutosaveStatus('saving'); if(_settingsAppearanceAutosaveTimer) clearTimeout(_settingsAppearanceAutosaveTimer); _settingsAppearanceAutosaveTimer=setTimeout(()=>_autosaveAppearanceSettings(payload),350); } async function _autosaveAppearanceSettings(payload){ try{ const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)}); _settingsAppearanceAutosaveRetryPayload=null; _rememberAppearanceSaved(payload); if(saved&&saved.font_size){ localStorage.setItem('hermes-font-size',saved.font_size); } _setAppearanceAutosaveStatus('saved'); }catch(e){ console.warn('[settings] appearance autosave failed', e); _setAppearanceAutosaveStatus('failed'); } } function _retryAppearanceAutosave(){ const payload=_settingsAppearanceAutosaveRetryPayload||_appearancePayloadFromUi(); _setAppearanceAutosaveStatus('saving'); _autosaveAppearanceSettings(payload); } async function loadSettingsPanel(){ try{ const settings=await api('/api/settings'); // Populate the version badge from the server — keeps it in sync with git // tags automatically without any manual release step. const vbadge=document.querySelector('.settings-version-badge'); if(vbadge && settings.webui_version) vbadge.textContent=settings.webui_version; // Hydrate appearance controls first so a slow /api/models request // cannot overwrite an in-progress theme/skin selection. const themeSel=$('settingsTheme'); const themeVal=settings.theme||'dark'; if(themeSel) themeSel.value=themeVal; if(typeof _syncThemePicker==='function') _syncThemePicker(themeVal); const skinVal=(settings.skin||'default').toLowerCase(); const skinSel=$('settingsSkin'); if(skinSel) skinSel.value=skinVal; if(typeof _buildSkinPicker==='function') _buildSkinPicker(skinVal); const fontSizeVal=settings.font_size||localStorage.getItem('hermes-font-size')||'default'; localStorage.setItem('hermes-font-size',fontSizeVal); if(typeof _applyFontSize==='function') _applyFontSize(fontSizeVal); const fontSizeSel=$('settingsFontSize'); if(fontSizeSel) fontSizeSel.value=fontSizeVal; if(typeof _syncFontSizePicker==='function') _syncFontSizePicker(fontSizeVal); // Workspace panel default-open toggle (localStorage-backed) // Uses a separate key (hermes-webui-workspace-panel-pref) so that // closing the panel via toolbar X does not clear the user's preference. const wsPanelCb=$('settingsWorkspacePanelOpen'); if(wsPanelCb){ wsPanelCb.checked=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'; wsPanelCb.onchange=function(){ const open=this.checked; localStorage.setItem('hermes-webui-workspace-panel-pref',open?'open':'closed'); // Also sync the runtime key so the current session reflects the change localStorage.setItem('hermes-webui-workspace-panel',open?'open':'closed'); document.documentElement.dataset.workspacePanel=open?'open':'closed'; if(open&&_workspacePanelMode==='closed') openWorkspacePanel('browse'); else if(!open&&_workspacePanelMode!=='closed') toggleWorkspacePanel(false); }; } const resolvedLanguage=(typeof resolvePreferredLocale==='function') ? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang')) : (settings.language || localStorage.getItem('hermes-lang') || 'en'); // Keep settings modal and current page strings in sync with the resolved locale. if(typeof setLocale==='function'){ setLocale(resolvedLanguage); if(typeof applyLocaleToDOM==='function') applyLocaleToDOM(); } // Populate model dropdown from /api/models + live model fetch (#872) const modelSel=$('settingsModel'); if(modelSel){ modelSel.innerHTML=''; let models=null; try{ models=await api('/api/models'); for(const g of ((models||{}).groups||[])){ const og=document.createElement('optgroup'); og.label=g.provider; if(g.provider_id) og.dataset.provider=g.provider_id; for(const m of g.models){ const opt=document.createElement('option'); opt.value=m.id;opt.textContent=m.label; og.appendChild(opt); } modelSel.appendChild(og); } // Append live-fetched models for the active provider, same as the // chat-header dropdown does via _fetchLiveModels() (#872). if(models.active_provider && typeof _fetchLiveModels==='function'){ _fetchLiveModels(models.active_provider, modelSel); } }catch(e){} _settingsHermesDefaultModelOnOpen=(models&&models.default_model)||''; // Use the smart matcher so a saved bare form like "anthropic/claude-opus-4.6" // (what the CLI's `hermes model` command writes) still selects the matching // `@nous:anthropic/claude-opus-4.6` option on a Nous setup. Without this, the // picker renders blank for any user whose default was persisted without the // @-prefix — CLI-first users, legacy installs, etc. if(typeof _applyModelToDropdown==='function'){ _applyModelToDropdown(_settingsHermesDefaultModelOnOpen, modelSel, (models&&models.active_provider)||window._activeProvider||null); }else{ modelSel.value=_settingsHermesDefaultModelOnOpen; } modelSel.addEventListener('change',_markSettingsDirty,{once:false}); } // Send key preference const sendKeySel=$('settingsSendKey'); if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});} // Language preference — populate from LOCALES bundle const langSel=$('settingsLanguage'); if(langSel){ langSel.innerHTML=''; if(typeof LOCALES!=='undefined'){ for(const [code,bundle] of Object.entries(LOCALES)){ const opt=document.createElement('option'); opt.value=code;opt.textContent=bundle._label||code; langSel.appendChild(opt); } } langSel.value=resolvedLanguage; langSel.addEventListener('change',_markSettingsDirty,{once:false}); } const showUsageCb=$('settingsShowTokenUsage'); if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_markSettingsDirty,{once:false});} const simplifiedToolCb=$('settingsSimplifiedToolCalling'); if(simplifiedToolCb){simplifiedToolCb.checked=settings.simplified_tool_calling!==false;simplifiedToolCb.addEventListener('change',_markSettingsDirty,{once:false});} const showCliCb=$('settingsShowCliSessions'); if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});} const syncCb=$('settingsSyncInsights'); if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});} const updateCb=$('settingsCheckUpdates'); if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});} const soundCb=$('settingsSoundEnabled'); if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});} // TTS settings (localStorage-only, no server round-trip needed) const ttsEnabledCb=$('settingsTtsEnabled'); if(ttsEnabledCb){ttsEnabledCb.checked=localStorage.getItem('hermes-tts-enabled')==='true';ttsEnabledCb.onchange=function(){localStorage.setItem('hermes-tts-enabled',this.checked?'true':'false');_applyTtsEnabled(this.checked);};} const ttsAutoReadCb=$('settingsTtsAutoRead'); if(ttsAutoReadCb){ttsAutoReadCb.checked=localStorage.getItem('hermes-tts-auto-read')==='true';ttsAutoReadCb.onchange=function(){localStorage.setItem('hermes-tts-auto-read',this.checked?'true':'false');};} // Populate voice selector from speechSynthesis const ttsVoiceSel=$('settingsTtsVoice'); if(ttsVoiceSel&&'speechSynthesis' in window){ const populateVoices=()=>{ const voices=speechSynthesis.getVoices(); const current=localStorage.getItem('hermes-tts-voice')||''; ttsVoiceSel.innerHTML=''; voices.forEach(v=>{ const opt=document.createElement('option'); opt.value=v.name;opt.textContent=v.name+(v.lang?' ('+v.lang+')':''); if(v.name===current) opt.selected=true; ttsVoiceSel.appendChild(opt); }); }; populateVoices(); speechSynthesis.addEventListener('voiceschanged',populateVoices,{once:true}); ttsVoiceSel.onchange=function(){localStorage.setItem('hermes-tts-voice',this.value);}; } // TTS rate/pitch sliders const ttsRateSlider=$('settingsTtsRate'); const ttsRateValue=$('settingsTtsRateValue'); if(ttsRateSlider){ const savedRate=localStorage.getItem('hermes-tts-rate'); ttsRateSlider.value=savedRate||'1'; if(ttsRateValue) ttsRateValue.textContent=parseFloat(ttsRateSlider.value).toFixed(1)+'x'; ttsRateSlider.oninput=function(){if(ttsRateValue)ttsRateValue.textContent=parseFloat(this.value).toFixed(1)+'x';localStorage.setItem('hermes-tts-rate',this.value);}; } const ttsPitchSlider=$('settingsTtsPitch'); const ttsPitchValue=$('settingsTtsPitchValue'); if(ttsPitchSlider){ const savedPitch=localStorage.getItem('hermes-tts-pitch'); ttsPitchSlider.value=savedPitch||'1'; if(ttsPitchValue) ttsPitchValue.textContent=parseFloat(ttsPitchSlider.value).toFixed(1); ttsPitchSlider.oninput=function(){if(ttsPitchValue)ttsPitchValue.textContent=parseFloat(this.value).toFixed(1);localStorage.setItem('hermes-tts-pitch',this.value);}; } const notifCb=$('settingsNotificationsEnabled'); if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_markSettingsDirty,{once:false});} // show_thinking has no settings panel checkbox — controlled via /reasoning show|hide const sidebarDensitySel=$('settingsSidebarDensity'); if(sidebarDensitySel){ sidebarDensitySel.value=settings.sidebar_density==='detailed'?'detailed':'compact'; sidebarDensitySel.addEventListener('change',_markSettingsDirty,{once:false}); } const autoTitleRefreshSel=$('settingsAutoTitleRefresh'); if(autoTitleRefreshSel){ const val=String(settings.auto_title_refresh_every||'0'); autoTitleRefreshSel.value=['0','5','10','20'].includes(val)?val:'0'; autoTitleRefreshSel.addEventListener('change',_markSettingsDirty,{once:false}); } // Busy input mode const busyInputModeSel=$('settingsBusyInputMode'); if(busyInputModeSel){ const val=String(settings.busy_input_mode||'queue'); busyInputModeSel.value=['queue','interrupt','steer'].includes(val)?val:'queue'; busyInputModeSel.addEventListener('change',_markSettingsDirty,{once:false}); } // Bot name const botNameField=$('settingsBotName'); if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});} // Password field: always blank (we don't send hash back) const pwField=$('settingsPassword'); if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});} // Show auth buttons only when auth is active try{ const authStatus=await api('/api/auth/status'); _setSettingsAuthButtonsVisible(!!authStatus.auth_enabled); }catch(e){} _syncHermesPanelSessionActions(); loadProvidersPanel(); // load provider cards in background switchSettingsSection(_settingsSection); }catch(e){ showToast(t('settings_load_failed')+e.message); } } // ── Providers panel ─────────────────────────────────────────────────────── const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn} async function loadProvidersPanel(){ const list=$('providersList'); const empty=$('providersEmpty'); if(!list) return; try{ const data=await api('/api/providers'); const providers=(data.providers||[]).filter(p=>p.configurable||p.is_oauth); list.innerHTML=''; _providerCardEls.clear(); if(providers.length===0){ list.style.display='none'; if(empty) empty.style.display=''; return; } if(empty) empty.style.display='none'; list.style.display=''; for(const p of providers){ list.appendChild(_buildProviderCard(p)); } }catch(e){ list.innerHTML='
Failed to load providers: '+e.message+'
'; } } function _buildProviderCard(p){ const card=document.createElement('div'); card.className='provider-card'; card.dataset.provider=p.id; // Use the is_oauth flag from the backend — it reflects _OAUTH_PROVIDERS in providers.py. // key_source can be 'oauth' (hermes auth), 'config_yaml' (token in config.yaml), or 'none'. const isOauth=p.is_oauth===true; const modelCount=Array.isArray(p.models)?p.models.length:0; const sourceLabel=p.key_source==='oauth' ? t('providers_status_oauth') : p.key_source==='config_yaml' ? t('providers_status_configured')||'Configured' : (p.has_key ? t('providers_status_api_key') : t('providers_status_not_configured_label')); const metaParts=[]; if(modelCount>0) metaParts.push(modelCount+(modelCount===1?' model':' models')); metaParts.push(sourceLabel); const metaText=metaParts.join(' · '); // Clickable header (toggles body) const header=document.createElement('button'); header.type='button'; header.className='provider-card-header'; header.innerHTML=`
${esc(p.display_name)}
${esc(metaText)}
${p.has_key?`${esc(t('providers_status_configured'))}`:''} `; card.appendChild(header); const body=document.createElement('div'); body.className='provider-card-body'; if(isOauth){ const hint=document.createElement('div'); hint.className='provider-card-hint'; if(p.key_source==='config_yaml'){ hint.textContent=t('providers_oauth_config_yaml_hint')||'Token configured via config.yaml. To update, edit the providers section in your config.yaml or run hermes auth.'; } else if(p.auth_error){ hint.textContent=p.auth_error; hint.style.color='var(--accent)'; } else if(p.has_key){ hint.textContent=t('providers_oauth_hint'); } else { hint.textContent=t('providers_oauth_not_configured_hint')||'Not authenticated. Run hermes auth in the terminal to configure this provider.'; hint.style.color='var(--muted)'; } body.appendChild(hint); card.appendChild(body); header.addEventListener('click',()=>card.classList.toggle('open')); return card; } const field=document.createElement('div'); field.className='provider-card-field'; const label=document.createElement('label'); label.className='provider-card-label'; label.textContent=t('providers_status_api_key'); field.appendChild(label); const row=document.createElement('div'); row.className='provider-card-row'; const input=document.createElement('input'); input.type='password'; input.className='provider-card-input'; input.placeholder=p.has_key?t('providers_key_placeholder_replace'):t('providers_key_placeholder_new'); input.autocomplete='off'; const toggleBtn=document.createElement('button'); toggleBtn.type='button'; toggleBtn.className='provider-card-btn provider-card-btn-ghost'; toggleBtn.textContent='Show'; toggleBtn.onclick=()=>{ const revealed=input.type==='text'; input.type=revealed?'password':'text'; toggleBtn.textContent=revealed?'Show':'Hide'; }; const saveBtn=document.createElement('button'); saveBtn.type='button'; saveBtn.className='provider-card-btn provider-card-btn-primary'; saveBtn.textContent=t('providers_save'); saveBtn.onclick=()=>_saveProviderKey(p.id); saveBtn.disabled=true; row.appendChild(input); row.appendChild(toggleBtn); row.appendChild(saveBtn); if(p.has_key){ const removeBtn=document.createElement('button'); removeBtn.type='button'; removeBtn.className='provider-card-btn provider-card-btn-danger'; removeBtn.textContent=t('providers_remove'); removeBtn.onclick=()=>_removeProviderKey(p.id); row.appendChild(removeBtn); } field.appendChild(row); body.appendChild(field); // Model list — show when provider has known models if(modelCount>0){ const modelSection=document.createElement('div'); modelSection.className='provider-card-models'; const modelLabel=document.createElement('div'); modelLabel.className='provider-card-label'; modelLabel.textContent='Models'; modelSection.appendChild(modelLabel); const modelList=document.createElement('div'); modelList.className='provider-card-model-tags'; for(const m of p.models){ const tag=document.createElement('span'); tag.className='provider-card-model-tag'; tag.textContent=m.id||m.label||m; modelList.appendChild(tag); } modelSection.appendChild(modelList); body.appendChild(modelSection); } // Refresh models for this provider const refreshRow=document.createElement('div'); refreshRow.className='provider-card-row'; refreshRow.style.marginTop='6px'; const refreshBtn=document.createElement('button'); refreshBtn.type='button'; refreshBtn.className='provider-card-btn provider-card-btn-ghost'; refreshBtn.style.display='flex'; refreshBtn.style.alignItems='center'; refreshBtn.style.gap='5px'; refreshBtn.innerHTML=` ${t('providers_refresh_models')||'Refresh Models'}`; refreshBtn.onclick=()=>_refreshProviderModels(p.id, refreshBtn); refreshRow.appendChild(refreshBtn); body.appendChild(refreshRow); card.appendChild(body); _providerCardEls.set(p.id,{card,input,saveBtn,hasKey:p.has_key}); input.addEventListener('input',()=>{saveBtn.disabled=!input.value.trim();}); header.addEventListener('click',e=>{ // Don't toggle when clicking inside body (defensive; body isn't inside header) if(e.target.closest('.provider-card-body')) return; card.classList.toggle('open'); if(card.classList.contains('open')) setTimeout(()=>input.focus(),0); }); return card; } async function _saveProviderKey(providerId){ const els=_providerCardEls.get(providerId); if(!els) return; const key=els.input.value.trim(); if(!key){ showToast(t('providers_enter_key')); return; } els.saveBtn.disabled=true; els.saveBtn.textContent=t('providers_saving'); try{ const res=await api('/api/providers',{method:'POST',body:JSON.stringify({provider:providerId,api_key:key})}); if(res.ok){ showToast(res.provider+' key '+res.action); els.input.value=''; await loadProvidersPanel(); // refresh list }else{ showToast(res.error||'Failed to save key'); els.saveBtn.disabled=false; els.saveBtn.textContent=t('providers_save'); } }catch(e){ showToast('Error: '+e.message); els.saveBtn.disabled=false; els.saveBtn.textContent=t('providers_save'); } } async function _removeProviderKey(providerId){ const els=_providerCardEls.get(providerId); if(!els) return; if(els.saveBtn){els.saveBtn.disabled=true;els.saveBtn.textContent=t('providers_removing');} try{ const res=await api('/api/providers/delete',{method:'POST',body:JSON.stringify({provider:providerId})}); if(res.ok){ showToast(res.provider+' key '+t('providers_key_removed').toLowerCase()); await loadProvidersPanel(); // refresh list }else{ showToast(res.error||'Failed to remove key'); if(els.saveBtn){els.saveBtn.disabled=false;els.saveBtn.textContent=t('providers_save');} } }catch(e){ showToast('Error: '+e.message); if(els.saveBtn){els.saveBtn.disabled=false;els.saveBtn.textContent=t('providers_save');} } } async function _refreshProviderModels(providerId, btn){ btn.disabled=true; const orig=btn.innerHTML; btn.innerHTML=` ${t('providers_refreshing')||'Refreshing...'}`; try{ const res=await api('/api/models/refresh',{method:'POST',body:JSON.stringify({provider:providerId})}); if(res.ok){ showToast(t('providers_models_refreshed')||('Models refreshed for '+res.provider)); }else{ showToast(res.error||'Failed to refresh models'); } }catch(e){ showToast('Error: '+e.message); }finally{ btn.disabled=false; btn.innerHTML=orig; } } function _setSettingsAuthButtonsVisible(active){ const signOutBtn=$('btnSignOut'); if(signOutBtn) signOutBtn.style.display=active?'':'none'; const disableBtn=$('btnDisableAuth'); if(disableBtn) disableBtn.style.display=active?'':'none'; } function _applySavedSettingsUi(saved, body, opts){ const {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts; window._sendKey=sendKey||'enter'; window._showTokenUsage=showTokenUsage; window._showCliSessions=showCliSessions; window._soundEnabled=body.sound_enabled; window._notificationsEnabled=body.notifications_enabled; window._showThinking=body.show_thinking!==false; window._simplifiedToolCalling=body.simplified_tool_calling!==false; window._sidebarDensity=sidebarDensity==='detailed'?'detailed':'compact'; window._busyInputMode=body.busy_input_mode||'queue'; window._botName=body.bot_name||'Hermes'; if(typeof applyBotName==='function') applyBotName(); if(typeof setLocale==='function') setLocale(language); if(typeof applyLocaleToDOM==='function') applyLocaleToDOM(); if(typeof startGatewaySSE==='function'){ if(showCliSessions) startGatewaySSE(); else if(typeof stopGatewaySSE==='function') stopGatewaySSE(); } _setSettingsAuthButtonsVisible(!!saved.auth_enabled); _settingsDirty=false; _settingsThemeOnOpen=theme; _settingsSkinOnOpen=skin||'default'; _settingsFontSizeOnOpen=fontSize||localStorage.getItem('hermes-font-size')||'default'; const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none'; _settingsHermesDefaultModelOnOpen=body.default_model||_settingsHermesDefaultModelOnOpen||''; // Sync window._defaultModel so newSession() uses the just-saved default without a reload (#908). if(body.default_model) window._defaultModel=body.default_model; if(typeof clearMessageRenderCache==='function') clearMessageRenderCache(); renderMessages(); if(typeof syncTopbar==='function') syncTopbar(); if(typeof renderSessionList==='function') renderSessionList(); } async function checkUpdatesNow(){ const btn=$('btnCheckUpdatesNow'); const label=$('checkUpdatesLabel'); const spinner=$('checkUpdatesSpinner'); const status=$('checkUpdatesStatus'); if(!btn||!label) return; // Disable button, show spinner btn.disabled=true; if(spinner) spinner.style.display=''; if(label) label.textContent=t('settings_checking'); if(status) status.textContent=''; try { const data=await api('/api/updates/check?force=1'); if(data.disabled){ if(status){status.textContent=t('settings_updates_disabled');status.style.color='var(--muted)';} } else { const parts=[]; if(data.webui&&data.webui.behind>0) parts.push('WebUI: '+data.webui.behind); if(data.agent&&data.agent.behind>0) parts.push('Agent: '+data.agent.behind); if(parts.length){ if(status){status.textContent=t('settings_updates_available').replace('{count}',parts.join(', '));status.style.color='var(--accent)';} // Also trigger the update banner if(typeof _showUpdateBanner==='function') _showUpdateBanner(data); } else { if(status){status.textContent=t('settings_up_to_date');status.style.color='var(--success)';} } } } catch(e){ // Never expose raw e.message in UI — log to console for debugging only console.warn('[checkUpdatesNow]', e); // Show a generic user-facing error; if the API returned a message body use it let userMsg=t('settings_update_check_failed'); if(e&&e.response){ try{ const body=JSON.parse(e.response); if(body.error) userMsg=String(body.error).substring(0,120); }catch(_){} } if(status){status.textContent=userMsg;status.style.color='var(--error)';} } finally { btn.disabled=false; if(spinner) spinner.style.display='none'; if(label) label.textContent=t('settings_check_now'); } } async function saveSettings(andClose){ const model=($('settingsModel')||{}).value; const modelChanged=(model||'')!==(_settingsHermesDefaultModelOnOpen||''); const sendKey=($('settingsSendKey')||{}).value; const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked; const showCliSessions=!!($('settingsShowCliSessions')||{}).checked; const pw=($('settingsPassword')||{}).value; const theme=($('settingsTheme')||{}).value||'dark'; const skin=($('settingsSkin')||{}).value||'default'; const fontSize=($('settingsFontSize')||{}).value||localStorage.getItem('hermes-font-size')||'default'; const language=($('settingsLanguage')||{}).value||'en'; const sidebarDensity=($('settingsSidebarDensity')||{}).value==='detailed'?'detailed':'compact'; const busyInputMode=($('settingsBusyInputMode')||{}).value||'queue'; const body={}; if(sendKey) body.send_key=sendKey; body.theme=theme; body.skin=skin; body.font_size=fontSize; body.language=language; body.show_token_usage=showTokenUsage; body.simplified_tool_calling=!!($('settingsSimplifiedToolCalling')||{}).checked; body.show_cli_sessions=showCliSessions; body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked; body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked; body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked; body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked; body.show_thinking=window._showThinking!==false; body.sidebar_density=sidebarDensity; body.busy_input_mode=busyInputMode; body.auto_title_refresh_every=(($('settingsAutoTitleRefresh')||{}).value||'0'); const botName=(($('settingsBotName')||{}).value||'').trim(); body.bot_name=botName||'Hermes'; // Password: only act if the field has content; blank = leave auth unchanged if(pw && pw.trim()){ try{ const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})}); if(modelChanged && model){ try{ await api('/api/default-model',{method:'POST',body:JSON.stringify({model})}); body.default_model=model; }catch(_modelErr){ if(typeof showToast==='function') showToast('Failed to update default model — settings saved'); } } _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity,fontSize}); showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated')); _settingsDirty=false; _resetSettingsPanelState(); if(!andClose) _pendingSettingsTargetPanel = null; if(andClose) _hideSettingsPanel(); return; }catch(e){showToast(t('settings_save_failed')+e.message);return;} } try{ const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); if(modelChanged && model){ try{ await api('/api/default-model',{method:'POST',body:JSON.stringify({model})}); body.default_model=model; }catch(_modelErr){ if(typeof showToast==='function') showToast('Failed to update default model — settings saved'); } } _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity,fontSize}); showToast(t('settings_saved')); _settingsDirty=false; _resetSettingsPanelState(); if(!andClose) _pendingSettingsTargetPanel = null; if(andClose) _hideSettingsPanel(); }catch(e){ showToast(t('settings_save_failed')+e.message); } } async function signOut(){ try{ await api('/api/auth/logout',{method:'POST',body:'{}'}); window.location.href='login'; }catch(e){ showToast(t('sign_out_failed')+e.message); } } async function disableAuth(){ const _disAuth=await showConfirmDialog({title:t('disable_auth_confirm_title'),message:t('disable_auth_confirm_message'),confirmLabel:t('disable'),danger:true,focusCancel:true}); if(!_disAuth) return; try{ await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})}); showToast(t('auth_disabled')); // Hide both auth buttons since auth is now off const disableBtn=$('btnDisableAuth'); if(disableBtn) disableBtn.style.display='none'; const signOutBtn=$('btnSignOut'); if(signOutBtn) signOutBtn.style.display='none'; }catch(e){ showToast(t('disable_auth_failed')+e.message); } } // ── Cron completion alerts ──────────────────────────────────────────────────── let _cronPollSince=Date.now()/1000; // track from page load let _cronPollTimer=null; let _cronUnreadCount=0; const _cronNewJobIds=new Set(); // track which job IDs had new completions (unread) // Auto-refresh the cron list when a job is created from chat or any external source. // The chat path dispatches this event when the agent response mentions cron creation. window.addEventListener('hermes:cron_created', () => { if ($('cronList')) loadCrons(); }); function startCronPolling(){ if(_cronPollTimer) return; _cronPollTimer=setInterval(async()=>{ if(document.hidden) return; // don't poll when tab is in background try{ const data=await api(`/api/crons/recent?since=${_cronPollSince}`); if(data.completions&&data.completions.length>0){ for(const c of data.completions){ showToast(t('cron_completion_status', c.name, c.status==='error' ? t('status_failed') : t('status_completed')),4000); _cronPollSince=Math.max(_cronPollSince,c.completed_at); if(c.job_id) _cronNewJobIds.add(String(c.job_id)); } // _cronUnreadCount is derived from _cronNewJobIds.size in updateCronBadge. updateCronBadge(); } }catch(e){} },30000); } function updateCronBadge(){ const tab=document.querySelector('.nav-tab[data-panel="tasks"]'); if(!tab) return; let badge=tab.querySelector('.cron-badge'); _cronUnreadCount=_cronNewJobIds.size; // sync counter to set (source of truth) if(_cronUnreadCount>0){ if(!badge){ badge=document.createElement('span'); badge.className='cron-badge'; tab.style.position='relative'; tab.appendChild(badge); } badge.textContent=_cronUnreadCount>9?'9+':_cronUnreadCount; badge.style.display=''; }else if(badge){ badge.style.display='none'; } } // Clear cron badge only when all unread jobs have been viewed (not on panel open) function _clearCronUnreadForJob(jobId){ const id=String(jobId); if(_cronNewJobIds.has(id)){ _cronNewJobIds.delete(id); updateCronBadge(); // re-derives _cronUnreadCount from set size } } const _origSwitchPanel=switchPanel; switchPanel=async function(name){ return _origSwitchPanel(name); }; // Start polling on page load startCronPolling(); // ── Background agent error tracking ────────────────────────────────────────── const _backgroundErrors=[]; // {session_id, title, message, ts} function trackBackgroundError(sessionId, title, message){ // Only track if user is NOT currently viewing this session if(S.session&&S.session.session_id===sessionId) return; _backgroundErrors.push({session_id:sessionId, title:title||t('untitled'), message, ts:Date.now()}); showErrorBanner(); } function showErrorBanner(){ let banner=$('bgErrorBanner'); if(!banner){ banner=document.createElement('div'); banner.id='bgErrorBanner'; banner.className='bg-error-banner'; const msgs=document.querySelector('.messages'); if(msgs) msgs.parentNode.insertBefore(banner,msgs); else document.body.appendChild(banner); } const latest=_backgroundErrors[0]; // FIFO: show oldest (first) error if(!latest){banner.style.display='none';return;} const count=_backgroundErrors.length; const msg=count>1?t('bg_error_multi',count):t('bg_error_single',latest.title); banner.innerHTML=`\u26a0 ${esc(msg)}
`; banner.style.display=''; } function navigateToErrorSession(){ const latest=_backgroundErrors.shift(); // FIFO: show oldest error first if(latest){ loadSession(latest.session_id);renderSessionList(); } if(_backgroundErrors.length===0) dismissErrorBanner(); else showErrorBanner(); } function dismissErrorBanner(){ _backgroundErrors.length=0; const banner=$('bgErrorBanner'); if(banner) banner.style.display='none'; } // Event wiring // ── MCP Server Management ── function loadMcpServers(){ const list=$('mcpServerList'); if(!list) return; api('/api/mcp/servers').then(r=>{ if(!r||!r.servers) return; if(!r.servers.length){ list.innerHTML=`
${t('mcp_no_servers')}
`; return; } list.innerHTML=r.servers.map(s=>{ const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+s.transport); const transportClass=s.transport==='http'?'mcp-http':s.transport==='stdio'?'mcp-stdio':'mcp-unknown'; const badge=`${esc(transportLabel)}`; const detail=s.transport==='http'?s.url:`${s.command} ${s.args?s.args.join(' '):''}`; const envInfo=s.env?Object.entries(s.env).map(([k,v])=>`${k}=${v}`).join(', '):''; return `
${esc(s.name)}${badge}
${esc(detail)}${envInfo?' | '+esc(envInfo):''}
`; }).join(''); }).catch(()=>{list.innerHTML=`
${t('mcp_load_failed')}
`}); // Delegate delete-button clicks — uses data-mcp-name to avoid inline onclick XSS if(list&&!list._mcpDeleteBound){ list._mcpDeleteBound=true; list.addEventListener('click',function(e){ const btn=e.target.closest('.mcp-delete-btn'); if(!btn) return; const name=btn.getAttribute('data-mcp-name'); if(name) deleteMcpServer(name); }); } } function showMcpAddForm(){ const wrap=$('mcpAddFormWrap'); if(wrap) wrap.style.display='block'; } function hideMcpAddForm(){ const wrap=$('mcpAddFormWrap'); if(wrap) wrap.style.display='none'; ['mcpName','mcpCommand','mcpArgs','mcpUrl','mcpTimeout'].forEach(id=>{ const el=$(id);if(el)el.value=id==='mcpTimeout'?'120':''; }); const tr=$('mcpTransport');if(tr)tr.value='stdio'; mcpTransportChanged(); } function mcpTransportChanged(){ const tr=$('mcpTransport'); const isHttp=tr&&tr.value==='http'; const cmdF=$('mcpCommandField');if(cmdF)cmdF.style.display=isHttp?'none':''; const argsF=$('mcpArgsField');if(argsF)argsF.style.display=isHttp?'none':''; const urlF=$('mcpUrlField');if(urlF)urlF.style.display=isHttp?'block':'none'; } function saveMcpServer(){ const name=($('mcpName')||{}).value||''; if(!name.trim()){showToast(t('mcp_name_required'));return;} const tr=($('mcpTransport')||{}).value||'stdio'; const timeout=parseInt(($('mcpTimeout')||{}).value)||120; const body={timeout}; if(tr==='http'){ body.url=($('mcpUrl')||{}).value||''; if(!body.url.trim()){showToast(t('mcp_url_required'));return;} }else{ body.command=($('mcpCommand')||{}).value||''; if(!body.command.trim()){showToast(t('mcp_command_required'));return;} const argsStr=($('mcpArgs')||{}).value||''; if(argsStr.trim()) body.args=argsStr.split(',').map(a=>a.trim()).filter(Boolean); } const encName=encodeURIComponent(name.trim()); api(`/api/mcp/servers/${encName}`,{method:'PUT',body:JSON.stringify(body)}) .then(r=>{ if(r&&r.ok){showToast(t('mcp_saved'));hideMcpAddForm();loadMcpServers();} else{showToast((r&&r.error)||t('mcp_save_failed'));} }).catch(()=>{showToast(t('mcp_save_failed'));}); } async function deleteMcpServer(name){ const _ok=await showConfirmDialog({title:t('mcp_delete_confirm_title'),message:t('mcp_delete_confirm_message',name),confirmLabel:t('delete_title'),danger:true,focusCancel:true}); if(!_ok) return; const encName=encodeURIComponent(name); api(`/api/mcp/servers/${encName}`,{method:'DELETE'}) .then(r=>{ if(r&&r.ok){showToast(t('mcp_deleted'));loadMcpServers();} else{showToast((r&&r.error)||t('mcp_delete_failed'));} }).catch(()=>{showToast(t('mcp_delete_failed'));}); } // Load MCP servers when system settings tab opens const _origSwitchSettings=switchSettingsSection; switchSettingsSection=function(name){ _origSwitchSettings(name); if(name==='system') loadMcpServers(); };