let _currentPanel = 'chat'; let _renamingAppTitlebar = false; // guard against re-entrant rename let _kanbanBoard = null; let _kanbanLatestEventId = 0; let _kanbanPollTimer = null; let _kanbanCurrentTaskId = null; let _kanbanLanesByProfile = true; // Multi-board state. _kanbanCurrentBoard is the slug of the active board // the UI is currently viewing. null means "use whatever the server reports // as active" (i.e. don't pin a specific board in API calls). The UI // persists the last-viewed slug to localStorage so refresh stays put. let _kanbanCurrentBoard = null; let _kanbanBoardsList = null; let _kanbanBoardMenuOpen = false; let _kanbanIsDispatching = false; let _kanbanSuppressCardClickUntil = 0; // SSE event stream — replaces the 30s polling cadence with a long-lived // /api/kanban/events/stream connection. Falls back to polling when the // EventSource fails to connect (proxy that strips text/event-stream, etc). let _kanbanEventSource = null; let _kanbanEventSourceFailures = 0; let _skillsData = null; // cached skills list let _cronList = null; // cached cron jobs (array) let _currentCronDetail = null; // full cron job object let _currentCronDetailKey = ''; let _cronMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit' let _cronPreFormDetail = null; // snapshot of prior selection when entering a form let _cronModelPickerTouched = false; // true once the user changes the model picker in the current form let _showAllCronProfiles = false; let _cronOtherProfileCount = 0; 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 let _logsAutoRefreshTimer = null; let _lastLogsLines = []; let _logsSeverityFilter = 'all'; // 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', insights: 'tab_insights', logs: 'tab_logs', settings: 'tab_settings', }; const MAIN_VIEW_PANELS = ['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs','plugin','selfmedia']; const MAIN_VIEW_SIDEBAR_PANEL_FALLBACKS = { plugin: '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 = ''; let sourceLabel = ''; 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') : []; subText = String(vis.length); sourceLabel = S.session.source_label || S.session.source_tag || S.session.raw_source || ''; // Recovered sidecars stamp source_label 'WebUI' (api/session_recovery.py); don't badge a native session as its own source (#3338). if (/^webui$/i.test(sourceLabel)) sourceLabel = ''; } 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 (panel !== 'chat') { const bot = typeof assistantDisplayName === 'function' ? assistantDisplayName() : ''; document.title = bot ? mainText + ' \u2014 ' + bot : mainText; } if (subEl) { if (subText) { subEl.textContent = subText; if (sourceLabel) { const badge = document.createElement('span'); badge.className = 'topbar-source-badge'; badge.textContent = sourceLabel + (S.session && S.session.read_only ? ' · read-only' : ''); subEl.appendChild(document.createTextNode(' ')); subEl.appendChild(badge); } 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 && !(S.session.read_only || S.session.is_read_only)) { 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(); }; } // Dismiss stale popover on session/panel switch const _existingPop = document.querySelector('.app-titlebar-title-popover'); if (_existingPop) { _existingPop.remove(); titleEl._titlePopover = null; if (titleEl._popoverOutsideHandler) { document.removeEventListener('click', titleEl._popoverOutsideHandler, true); titleEl._popoverOutsideHandler = null; } } // Mobile touch interactions if ('ontouchstart' in window) { // Tap-to-reveal full title popover — wired once per element lifetime if (!titleEl._mobileTouchWired) { titleEl._mobileTouchWired = true; titleEl._titlePopover = null; const _dismissTitlePopover = () => { if (titleEl._titlePopover) { titleEl._titlePopover.remove(); titleEl._titlePopover = null; } if (titleEl._popoverOutsideHandler) { document.removeEventListener('click', titleEl._popoverOutsideHandler, true); titleEl._popoverOutsideHandler = null; } }; titleEl.addEventListener('click', function _onTitleClick(e) { if (_renamingAppTitlebar) return; if (titleEl._titlePopover) { _dismissTitlePopover(); return; } e.stopPropagation(); const pop = document.createElement('div'); pop.className = 'app-titlebar-title-popover'; pop.textContent = (S && S.session && S.session.title) || (typeof t === 'function' ? t('untitled') : 'Untitled'); document.body.appendChild(pop); const rect = titleEl.getBoundingClientRect(); pop.style.top = (rect.bottom + 6) + 'px'; pop.style.left = Math.max(8, rect.left) + 'px'; pop.style.maxWidth = (window.innerWidth - 16) + 'px'; titleEl._titlePopover = pop; const _outside = titleEl._popoverOutsideHandler = (ev) => { if (!pop.contains(ev.target) && ev.target !== titleEl) { _dismissTitlePopover(); document.removeEventListener('click', _outside, true); titleEl._popoverOutsideHandler = null; } }; setTimeout(() => document.addEventListener('click', _outside, true), 0); }, { passive: true }); } // Long-press → session action menu (re-evaluated each sync so late-arriving sessions attach) if (!titleEl._mobileLpWired && panel === 'chat' && S && S.session && !S.session.read_only && !S.session.is_read_only && typeof _openSessionActionMenu === 'function') { titleEl._mobileLpWired = true; let _lpTimer = null; let _lpHandled = false; let _lpStartX = 0, _lpStartY = 0; const _lpDelay = typeof SESSION_LONG_PRESS_DELAY_MS !== 'undefined' ? SESSION_LONG_PRESS_DELAY_MS : 400; titleEl.addEventListener('touchstart', (e) => { const touch = e.changedTouches && e.changedTouches[0]; if (!touch) return; if (_lpTimer) { clearTimeout(_lpTimer); _lpTimer = null; } _lpHandled = false; _lpStartX = touch.clientX; _lpStartY = touch.clientY; titleEl.classList.add('long-pressing'); _lpTimer = setTimeout(() => { _lpTimer = null; if (_lpHandled) return; _lpHandled = true; titleEl.classList.remove('long-pressing'); _openSessionActionMenu(S.session, titleEl); }, _lpDelay); }, { passive: true }); titleEl.addEventListener('touchmove', (e) => { if (!_lpTimer) return; const touch = e.changedTouches && e.changedTouches[0]; if (!touch) return; if (Math.abs(touch.clientX - _lpStartX) > 10 || Math.abs(touch.clientY - _lpStartY) > 10) { clearTimeout(_lpTimer); _lpTimer = null; titleEl.classList.remove('long-pressing'); } }, { passive: true }); titleEl.addEventListener('touchend', (e) => { clearTimeout(_lpTimer); _lpTimer = null; titleEl.classList.remove('long-pressing'); if (_lpHandled) { e.preventDefault(); e.stopPropagation(); } }, { passive: false }); titleEl.addEventListener('touchcancel', () => { clearTimeout(_lpTimer); _lpTimer = null; _lpHandled = false; titleEl.classList.remove('long-pressing'); }, { passive: true }); } } } function _beginSettingsPanelSession() { _settingsIndex = null; _settingsIndexPromise = null; // Invalidate any in-flight search render from a PRIOR Settings session and // reset the search UI, so a slow index build that resolves after the panel // was closed/reopened can't paint stale results into the dropdown. #4340 // review fix (filterSettings() bails when its captured seq != current). ++_settingsSearchSeq; const _searchInput = $('settingsSearch'); if (_searchInput) _searchInput.value = ''; const _searchResults = $('settingsSearchResults'); if (_searchResults) { _searchResults.style.display = 'none'; _searchResults.innerHTML = ''; } _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; if (!_settingsSearchDismissListenerRegistered) { _settingsSearchDismissListenerRegistered = true; document.addEventListener('click', e => { if (!e.target.closest('#settingsMenu')) { // Invalidate an in-flight first-build too, so it can't resurrect the // dropdown after an outside-click dismiss. #4340 review fix. ++_settingsSearchSeq; const r = $('settingsSearchResults'); if (r) { r.style.display = 'none'; r.innerHTML = ''; } } }); } _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; } function _resyncChatSidebarAfterPanelSwitch() { if (_currentPanel !== 'chat') return; if (typeof renderSessionListFromCache !== 'function') return; const run = () => { if (_currentPanel !== 'chat') return; if (typeof _renamingSid !== 'undefined' && _renamingSid) return; // If the user opens the per-conversation action menu immediately after // returning to Chat, do not let the deferred sidebar resync tear it down. // renderSessionListFromCache() intentionally closes that menu before it // rebuilds rows, which is correct for normal list refreshes but hostile to // this one-shot panel-transition repair. if (typeof _sessionActionMenu !== 'undefined' && _sessionActionMenu) return; renderSessionListFromCache(); }; if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run); else run(); } function _closeMobileSidebarAfterPanelSelection(){ if(typeof closeMobileSidebar!=='function')return; if(typeof _isDesktopWidth==='function'&&_isDesktopWidth())return; closeMobileSidebar(); } function _panelFromCurrentMainView(){ const mainEl=document.querySelector('main.main'); if(!mainEl)return _currentPanel||'chat'; for(const panel of MAIN_VIEW_PANELS){ if(mainEl.classList.contains('showing-'+panel))return MAIN_VIEW_SIDEBAR_PANEL_FALLBACKS[panel]||panel; } if(_currentPanel&&$('panel'+_currentPanel.charAt(0).toUpperCase()+_currentPanel.slice(1)))return _currentPanel; return 'chat'; } function _syncMobileSidebarPanelFromMainView(){ const mainEl=document.querySelector('main.main'); // Extension panels are intentionally outside MAIN_VIEW_PANELS: the host must // not try to lazy-load or own their main view. They do, however, publish the // visible view as `showing-x-` and install a matching sidebar // `.panel-view[data-panel-token]`. The mobile drawer is reopened through this // sync helper, so treating that state as Chat deactivated the extension's // sidebar view every time the operator opened the hamburger or edge drawer. // The frame remained behind the drawer, but its content had no reachable // navigation state on the phone. const extensionClass=mainEl&&Array.from(mainEl.classList) .find(name=>name.startsWith('showing-x-')); const extensionToken=extensionClass&&extensionClass.slice('showing-x-'.length); if(extensionToken){ const extensionView=Array.from(document.querySelectorAll('.sidebar .panel-view')) .find(view=>view.dataset.panelToken===extensionToken); if(extensionView){ const extensionPanel=`x-${extensionToken}`; document.querySelectorAll('[data-panel]').forEach(t=>t.classList.toggle('active',t.dataset.panel===extensionPanel)); document.querySelectorAll('.panel-view').forEach(p=>p.classList.remove('active')); extensionView.classList.add('active'); // Do not put an extension token in _currentPanel: switchPanel owns that // state and only accepts its native panel names. Returning the token keeps // this helper truthful without corrupting the host state machine. return extensionPanel; } } const panel=_panelFromCurrentMainView(); if(!panel)return _currentPanel||'chat'; const panelEl=$('panel'+panel.charAt(0).toUpperCase()+panel.slice(1)); if(!panelEl)return _currentPanel||'chat'; _currentPanel=panel; document.querySelectorAll('[data-panel]').forEach(t=>t.classList.toggle('active',t.dataset.panel===panel)); document.querySelectorAll('.panel-view').forEach(p=>p.classList.remove('active')); panelEl.classList.add('active'); return panel; } async function switchPanel(name, opts = {}) { const nextPanel = name || 'chat'; const prevPanel = _currentPanel; // ── Desktop sidebar collapse toggle (rail-click only) ── // If the click came from a rail icon AND we're on desktop, the rail icon // does double duty: clicking the already-active panel collapses the sidebar; // clicking any panel while collapsed expands first. Programmatic switches // (no opts.fromRailClick) are unaffected so legacy callers preserve // behaviour exactly. if (opts.fromRailClick && typeof _isSidebarCollapsed === 'function' && typeof _isDesktopWidth === 'function' && _isDesktopWidth()) { if (_isSidebarCollapsed()) { // Expand first, then continue to the normal panel switch below so // the clicked panel becomes (or stays) active in the same gesture. expandSidebar(); } else if (prevPanel === nextPanel) { // Same panel clicked while sidebar is open → collapse and short-circuit. // Skip the guard/cleanup work below; nothing about the active panel // is changing, only the visibility of the panel container. toggleSidebar(true); return false; } } if (!opts.bypassSettingsGuard && !_beforePanelSwitch(nextPanel)) return false; if (prevPanel !== 'settings' && nextPanel === 'settings') _beginSettingsPanelSession(); // Close any long-lived Kanban SSE stream when leaving the kanban panel // so we don't keep a stale connection open in the background. if (prevPanel === 'kanban' && nextPanel !== 'kanban') { if (typeof _kanbanStopPolling === 'function') _kanbanStopPolling(); } _currentPanel = nextPanel; // Mobile drawer visibility: a rail/tab click on a phone should surface the // panel synchronously, NOT after the panel's async data load. If the re-open // stayed at the bottom of this function, a form opened from inside the drawer // (e.g. openWorkspaceCreate closing the drawer) would race the deferred // re-open and the drawer would win, covering the main-view form. if (opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth()) { const sidebar = document.querySelector('.sidebar'); if (sidebar) { sidebar.classList.remove('mobile-session-page'); sidebar.classList.add('mobile-panel-drawer', 'mobile-open'); } } // Update nav tabs (rail + mobile sidebar-nav share data-panel) document.querySelectorAll('[data-panel]').forEach(t => t.classList.toggle('active', t.dataset.panel === nextPanel)); // Refresh aria-expanded on the newly-active rail button to mirror sidebar state. if (typeof _syncSidebarAria === 'function') _syncSidebarAria(); // 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'); // Update 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) { MAIN_VIEW_PANELS.forEach(p => { mainEl.classList.toggle('showing-' + p, nextPanel === p); }); } // Lazy-load panel data if (nextPanel === 'tasks') await loadCrons(); if (nextPanel === 'kanban') await loadKanban(); 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 === 'insights') await loadInsights(); if (nextPanel === 'logs') await loadLogs(); if (nextPanel === 'selfmedia') await loadSelfMediaPanel(); _syncLogsAutoRefresh(); if (typeof _syncSystemHealthMonitorVisibility === 'function') _syncSystemHealthMonitorVisibility(); if (nextPanel === 'settings') { switchSettingsSection(_currentSettingsSection); loadSettingsPanel(); } _resyncChatSidebarAfterPanelSwitch(); if (nextPanel === 'chat' && typeof syncTopbar === 'function') syncTopbar(); else syncAppTitlebar(); return true; } // ── Cron panel ── function _isRecurringCronJob(job) { const kind = job && job.schedule && job.schedule.kind; return kind === 'cron' || kind === 'interval'; } function _cronScheduleKindForInput(value) { const schedule = String(value || '').trim(); if (!schedule) return ''; const lower = schedule.toLowerCase(); if (lower.startsWith('every ')) return 'interval'; if (lower.startsWith('@')) return 'cron'; const parts = schedule.split(/\s+/); if (parts.length >= 5 && parts.slice(0, 5).every(p => /^[\d*\-,/]+$/.test(p))) return 'cron'; if (schedule.includes('T') || /^\d{4}-\d{2}-\d{2}/.test(schedule)) return 'once'; if (/^\d+\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i.test(schedule)) return 'once'; return ''; } function _syncCronScheduleWarning() { const input = $('cronFormSchedule'); const warning = $('cronFormScheduleOnceWarning'); if (!input || !warning) return; warning.style.display = _cronScheduleKindForInput(input.value) === 'once' ? '' : 'none'; _syncCronSchedulePreview(); } // Live preview of the generated cron expression in the preset hint line (the // cron-job.org / GitHub-schedule-editor convention) so a cron-literate user sees // exactly what the friendly controls produce. Empty on Custom (the raw field is shown). function _syncCronSchedulePreview() { const preview = $('cronFormSchedulePreview'); const presetEl = $('cronFormSchedulePreset'); const scheduleEl = $('cronFormSchedule'); if (!preview) return; const presetId = presetEl ? presetEl.value : 'custom'; const expr = String((scheduleEl && scheduleEl.value) || '').trim(); preview.textContent = (presetId !== 'custom' && expr) ? `${expr} · ` : ''; } const CRON_SCHEDULE_PRESETS = [ { id: 'hourly', label: 'cron_schedule_preset_hourly', fallback: 'Hourly', fields: ['minute'], defaults: { minute: 0 } }, { id: 'daily', label: 'cron_schedule_preset_daily', fallback: 'Daily', fields: ['time'], defaults: { hour: 9, minute: 0 } }, { id: 'weekdays', label: 'cron_schedule_preset_weekdays', fallback: 'Weekdays (Mon–Fri)', fields: ['time'], defaults: { hour: 9, minute: 0 } }, { id: 'weekly', label: 'cron_schedule_preset_weekly', fallback: 'Weekly', fields: ['weekday', 'time'], defaults: { hour: 9, minute: 0, weekday: 1 } }, { id: 'monthly', label: 'cron_schedule_preset_monthly', fallback: 'Monthly', fields: ['monthDay', 'time'], defaults: { hour: 9, minute: 0, monthDay: 1 } }, { id: 'custom', label: 'cron_schedule_preset_custom', fallback: 'Custom', fields: [] }, ]; function _cronSchedulePresetOptionHtml() { return CRON_SCHEDULE_PRESETS .map((preset) => ``) .join(''); } function _cronSchedulePresetForId(presetId) { return CRON_SCHEDULE_PRESETS.find((entry) => entry.id === presetId) || null; } function _cronSchedulePresetControlIds() { return { time: 'cronFormScheduleTime', minute: 'cronFormScheduleMinute', weekday: 'cronFormScheduleWeekday', monthDay: 'cronFormScheduleMonthDay', }; } // Which visible control wrapper each logical field lives in. `hour`+`minute` for // time-based presets share the single #cronFormScheduleTime picker (in the Time // field); `minute` alone (Hourly) uses the standalone Minute field. function _cronSchedulePresetFieldWrapId(field) { if (field === 'time' || field === 'hour') return 'cronFormScheduleTimeField'; if (field === 'minute') return 'cronFormScheduleMinuteField'; if (field === 'weekday') return 'cronFormScheduleWeekdayField'; if (field === 'monthDay') return 'cronFormScheduleMonthDayField'; return ''; } function _cronSchedulePresetFieldId(field) { const ids = _cronSchedulePresetControlIds(); return ids[field] || ''; } function _cronSchedulePresetFieldEl(field) { const id = _cronSchedulePresetFieldId(field); return id ? $(id) : null; } function _cronSchedulePresetBounds(field) { if (field === 'hour') return { min: 0, max: 23 }; if (field === 'minute') return { min: 0, max: 59 }; if (field === 'weekday') return { min: 0, max: 6 }; if (field === 'monthDay') return { min: 1, max: 31 }; return { min: 0, max: 999 }; } function _cronSchedulePresetNormalizeValue(field, value, fallback) { const bounds = _cronSchedulePresetBounds(field); const parsed = parseInt(String(value ?? '').trim(), 10); const fallbackParsed = parseInt(String(fallback ?? bounds.min).trim(), 10); const safeFallback = Number.isFinite(fallbackParsed) ? fallbackParsed : bounds.min; const n = Number.isFinite(parsed) ? parsed : safeFallback; return String(Math.min(bounds.max, Math.max(bounds.min, n))); } function _cronSchedulePresetValueForField(field, fallback) { // hour/minute for time-based presets come from the single #cronFormScheduleTime // picker ("HH:MM"); the standalone Minute box (Hourly) still reads directly. if (field === 'hour' || field === 'minute') { const timeEl = $('cronFormScheduleTime'); const minuteBox = $('cronFormScheduleMinute'); // Hourly uses the standalone minute box; if the Time picker isn't the visible // source for minute, prefer the minute box when it's the shown control. if (field === 'minute' && minuteBox && (!timeEl || _cronScheduleMinuteBoxIsActive())) { return _cronSchedulePresetNormalizeValue('minute', minuteBox.value, fallback); } if (timeEl && /^\d{1,2}:\d{2}$/.test(String(timeEl.value || '').trim())) { const [h, m] = String(timeEl.value).trim().split(':'); return _cronSchedulePresetNormalizeValue(field, field === 'hour' ? h : m, fallback); } return _cronSchedulePresetNormalizeValue(field, fallback, fallback); } const el = _cronSchedulePresetFieldEl(field); const raw = el ? String(el.value || '').trim() : ''; return _cronSchedulePresetNormalizeValue(field, raw, fallback); } // The standalone Minute box is the active minute source only for the Hourly preset // (the only preset whose visible fields include a bare 'minute'). function _cronScheduleMinuteBoxIsActive() { const presetEl = $('cronFormSchedulePreset'); return !!(presetEl && presetEl.value === 'hourly'); } function _cronSchedulePresetRawFieldInBounds(field, value) { const raw = String(value || '').trim(); if (!/^\d+$/.test(raw)) return false; const bounds = _cronSchedulePresetBounds(field); const parsed = parseInt(raw, 10); return Number.isFinite(parsed) && parsed >= bounds.min && parsed <= bounds.max; } function _cronSchedulePresetApplyValues(values) { // Write hour/minute into the single time picker as zero-padded HH:MM. if (values.hour != null || values.minute != null) { const timeEl = $('cronFormScheduleTime'); if (timeEl) { const h = _cronSchedulePresetNormalizeValue('hour', values.hour, values.hour ?? 9); const m = _cronSchedulePresetNormalizeValue('minute', values.minute, values.minute ?? 0); timeEl.value = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; } } if (values.minute != null) { const minuteBox = $('cronFormScheduleMinute'); if (minuteBox) minuteBox.value = _cronSchedulePresetNormalizeValue('minute', values.minute, values.minute); } ['weekday', 'monthDay'].forEach((field) => { const el = _cronSchedulePresetFieldEl(field); if (!el || values[field] == null) return; el.value = _cronSchedulePresetNormalizeValue(field, values[field], values[field]); }); } function _cronSchedulePresetSyncVisibility(presetId) { const wrapper = $('cronFormSchedulePresetParams'); const customRow = $('cronFormScheduleCustomRow'); const preset = _cronSchedulePresetForId(presetId); const showFields = preset && Array.isArray(preset.fields) ? preset.fields : []; const isCustom = presetId === 'custom'; // Preset param controls hide entirely on Custom; the raw cron expression row // shows ONLY on Custom (kept in the DOM as a hidden field otherwise). if (wrapper) wrapper.style.display = isCustom ? 'none' : ''; if (customRow) customRow.style.display = isCustom ? '' : 'none'; ['time', 'minute', 'weekday', 'monthDay'].forEach((field) => { const fieldWrap = $(_cronSchedulePresetFieldWrapId(field)); if (fieldWrap) fieldWrap.style.display = showFields.includes(field) ? '' : 'none'; }); } function _cronSchedulePresetValuesForSelection(presetId) { const preset = _cronSchedulePresetForId(presetId); const defaults = (preset && preset.defaults) || {}; if (presetId === 'hourly') { return { minute: _cronSchedulePresetValueForField('minute', defaults.minute) }; } if (presetId === 'daily' || presetId === 'weekdays') { return { hour: _cronSchedulePresetValueForField('hour', defaults.hour), minute: _cronSchedulePresetValueForField('minute', defaults.minute), }; } if (presetId === 'weekly') { return { hour: _cronSchedulePresetValueForField('hour', defaults.hour), minute: _cronSchedulePresetValueForField('minute', defaults.minute), weekday: _cronSchedulePresetValueForField('weekday', defaults.weekday), }; } if (presetId === 'monthly') { return { hour: _cronSchedulePresetValueForField('hour', defaults.hour), minute: _cronSchedulePresetValueForField('minute', defaults.minute), monthDay: _cronSchedulePresetValueForField('monthDay', defaults.monthDay), }; } return {}; } function _cronSchedulePresetValueForSelection(presetId, selectedValues) { const values = selectedValues || _cronSchedulePresetValuesForSelection(presetId); if (presetId === 'hourly') return `${values.minute} * * * *`; if (presetId === 'daily') return `${values.minute} ${values.hour} * * *`; if (presetId === 'weekdays') return `${values.minute} ${values.hour} * * 1-5`; if (presetId === 'weekly') return `${values.minute} ${values.hour} * * ${values.weekday}`; if (presetId === 'monthly') return `${values.minute} ${values.hour} ${values.monthDay} * *`; return ''; } function _cronSchedulePresetStateForInput(value) { const schedule = String(value || '').trim(); if (!schedule) return { presetId: 'custom' }; if (/^every\s+1h$/i.test(schedule)) { return { presetId: 'hourly', minute: '0' }; } if (schedule.startsWith('@')) return { presetId: 'custom' }; const parts = schedule.split(/\s+/); if (parts.length !== 5) return { presetId: 'custom' }; const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; if (!_cronSchedulePresetRawFieldInBounds('minute', minute)) return { presetId: 'custom' }; if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { return { presetId: 'daily', minute, hour }; } if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && dayOfWeek === '1-5') { return { presetId: 'weekdays', minute, hour }; } if (hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { return { presetId: 'hourly', minute }; } if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && (_cronSchedulePresetRawFieldInBounds('weekday', dayOfWeek) || dayOfWeek === '7')) { return { presetId: 'weekly', minute, hour, weekday: dayOfWeek === '7' ? '0' : dayOfWeek }; } if (_cronSchedulePresetRawFieldInBounds('hour', hour) && _cronSchedulePresetRawFieldInBounds('monthDay', dayOfMonth) && month === '*' && dayOfWeek === '*') { return { presetId: 'monthly', minute, hour, monthDay: dayOfMonth }; } return { presetId: 'custom' }; } function _cronSchedulePresetIdForValue(value) { return _cronSchedulePresetStateForInput(value).presetId; } function _syncCronSchedulePresetFromInput() { const presetEl = $('cronFormSchedulePreset'); const scheduleEl = $('cronFormSchedule'); if (!presetEl || !scheduleEl) return; const state = _cronSchedulePresetStateForInput(scheduleEl.value); presetEl.value = state.presetId; _cronSchedulePresetSyncVisibility(state.presetId); if (state.presetId !== 'custom') _cronSchedulePresetApplyValues(state); } function _syncCronSchedulePresetAndWarning() { _syncCronSchedulePresetFromInput(); _syncCronScheduleWarning(); } function _applyCronSchedulePresetSelection() { const presetEl = $('cronFormSchedulePreset'); const scheduleEl = $('cronFormSchedule'); if (!presetEl || !scheduleEl) return; const presetId = presetEl.value; if (presetId !== 'custom') { const values = _cronSchedulePresetValuesForSelection(presetId); _cronSchedulePresetApplyValues(values); scheduleEl.value = _cronSchedulePresetValueForSelection(presetId, values); _cronSchedulePresetSyncVisibility(presetId); _syncCronScheduleWarning(); return; } _cronSchedulePresetSyncVisibility(presetId); _syncCronScheduleWarning(); } // Regenerate the cron expression from the current field values WITHOUT writing the // clamped values back into the field the user is editing — so typing into the Minute // box (or the time picker) doesn't snap a half-entered value to the default/clamp // mid-keystroke. Value clamping still happens on `change`/blur via the full apply. function _regenCronScheduleFromFields() { const presetEl = $('cronFormSchedulePreset'); const scheduleEl = $('cronFormSchedule'); if (!presetEl || !scheduleEl) return; const presetId = presetEl.value; if (presetId === 'custom') return; scheduleEl.value = _cronSchedulePresetValueForSelection(presetId); _syncCronScheduleWarning(); } function _initCronSchedulePresetControls() { const presetEl = $('cronFormSchedulePreset'); const scheduleEl = $('cronFormSchedule'); if (!presetEl || !scheduleEl) return; presetEl.addEventListener('change', _applyCronSchedulePresetSelection); if ($('cronFormSchedulePresetParams')) { ['cronFormScheduleTime', 'cronFormScheduleMinute', 'cronFormScheduleWeekday', 'cronFormScheduleMonthDay'].forEach((id) => { const el = $(id); if (!el) return; // On `change`/blur, clamp + normalize (writes values back). On `input` // (per-keystroke), only regenerate the expression — never rewrite the field // being typed into, so partial input like clearing Hour to type "14" isn't // snapped to the default (#5554 UX fix). el.addEventListener('change', _applyCronSchedulePresetSelection); el.addEventListener('input', _regenCronScheduleFromFields); }); } // On raw-cron `input` (only reachable on Custom, where the raw row is shown), // update ONLY the warning + preview — do NOT re-detect the preset or sync // visibility, or a partial value momentarily matching a preset (e.g. typing // "0 9 * * 1,3" transiently equals Weekly's "0 9 * * 1") would switch the preset // and hide the focused raw field mid-keystroke (#5554). Preset re-detection runs // on initial render and on `change`/blur. scheduleEl.addEventListener('input', _syncCronScheduleWarning); scheduleEl.addEventListener('change', _syncCronSchedulePresetAndWarning); _syncCronSchedulePresetAndWarning(); } 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 _cronProfileName(profile){ return (profile || '').toString().trim(); } function _cronProfileLabel(profile){ const name = _cronProfileName(profile); return name || (t('cron_profile_server_default') || 'server default'); } function _cronProfileTitle(profile){ const name = _cronProfileName(profile); if (name) return (t('cron_profile_label') || 'Profile') + ': ' + name; return t('cron_profile_server_default_hint') || 'Uses the WebUI server default profile at run time'; } function _cronOwnerProfileName(job){ return _cronProfileName(job && (job.owner_profile ?? job.profile)); } function _cronJobKey(job){ return `${_cronOwnerProfileName(job)}\u0000${String(job && job.id || '')}`; } function _cronItemId(job){ return 'cron-' + encodeURIComponent(_cronJobKey(job)); } function _cronDetailMatches(jobId, detailKey){ return !!( detailKey && _currentCronDetail && !_currentCronDetail.read_only && String(_currentCronDetail.id) === String(jobId) && _currentCronDetailKey === detailKey && _cronJobKey(_currentCronDetail) === detailKey ); } function _findCronJob(jobOrId){ if (jobOrId && typeof jobOrId === 'object') return jobOrId; const id = String(jobOrId || ''); if (!_cronList || !id) return null; return _cronList.find(j => !j.read_only && String(j.id) === id) || _cronList.find(j => String(j.id) === id) || null; } function _appendCronProfileToggle(parent){ if (!parent || (!_showAllCronProfiles && _cronOtherProfileCount <= 0)) return; const wrap = document.createElement('div'); wrap.style.cssText = 'padding:10px 0 0'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'sm-btn'; btn.style.cssText = 'width:100%;justify-content:center'; btn.textContent = _showAllCronProfiles ? 'Show active profile only' : `Show ${_cronOtherProfileCount} from other profiles`; btn.onclick = async () => { _showAllCronProfiles = !_showAllCronProfiles; await loadCrons(); }; wrap.appendChild(btn); parent.appendChild(wrap); } async function loadCronProfiles(){ if (_cronProfilesCache) return _cronProfilesCache; try { const data = await api('/api/profiles'); _cronProfilesCache = Array.isArray(data.profiles) ? data.profiles : []; } catch(e) { _cronProfilesCache = []; } return _cronProfilesCache; } function _cronProfileOptions(selected){ const current = _cronProfileName(selected); const profiles = Array.isArray(_cronProfilesCache) ? _cronProfilesCache : []; const seen = new Set(['']); const opts = [``]; for (const p of profiles) { const name = _cronProfileName(p && p.name); if (!name || seen.has(name)) continue; seen.add(name); const label = p && p.is_default ? `${name} (${t('default') || 'default'})` : name; opts.push(``); } if (current && !seen.has(current)) { opts.push(``); } return opts.join(''); } function _refreshCronProfileSelect(selected){ const sel = $('cronFormProfile'); if (!sel) return; const keep = selected === undefined ? sel.value : selected; sel.innerHTML = _cronProfileOptions(keep); } 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); } function _gatewayStatusReason(status) { const health = status && typeof status.health === 'object' ? status.health : null; if (!health) return ''; return typeof health.reason === 'string' ? health.reason.trim() : ''; } function _cronGatewayNoticeHtml(status) { if (!status || (status.configured && status.running)) return ''; const reason = _gatewayStatusReason(status); const isStaleMetadata = reason === 'gateway_stale_running_state'; const isRemoteUnreachable = reason === 'remote_gateway_unreachable'; const notConfigured = !status.configured; const title = notConfigured ? 'Gateway not configured' : isStaleMetadata ? 'Gateway metadata stale' : isRemoteUnreachable ? 'Gateway endpoint not reachable' : 'Gateway not running'; const body = notConfigured ? 'In Hermes WebUI, scheduled jobs require the Hermes gateway daemon. If this is a single-container Docker install, jobs can be created and run manually here, but scheduled ticks need a gateway container or `hermes gateway` running outside the WebUI.' : isStaleMetadata ? 'The gateway is marked as configured, but its health metadata has gone stale. In Docker, scheduled jobs require a live gateway daemon that refreshes runtime metadata while ticking cron.' : isRemoteUnreachable ? 'The gateway health endpoint is not reachable from WebUI. Verify the configured gateway URL env var (`GATEWAY_HEALTH_URL`, `HERMES_GATEWAY_HEALTH_URL`, `HERMES_API_URL`, or `HERMES_WEBUI_GATEWAY_BASE_URL`) points to a reachable gateway service and network path before relying on cron ticking.' : 'In Hermes WebUI, scheduled jobs require the Hermes gateway daemon to be running. Start the gateway container or `hermes gateway` before relying on offline scheduled runs.'; const docsHref = 'https://github.com/nesquena/hermes-webui/blob/master/docs/docker.md#scheduled-jobs-and-the-gateway-daemon'; const helpLink = notConfigured || isRemoteUnreachable || isStaleMetadata ? `

How to enable scheduled jobs in Docker ↗

` : ''; return `
${esc(title)}

${esc(body)}

${helpLink} `; } async function loadCronGatewayNotice() { const box = $('cronGatewayNotice'); if (!box) return; try { const status = await api('/api/gateway/status'); const html = _cronGatewayNoticeHtml(status); if (html) { box.innerHTML = html; box.style.display = ''; } else { box.innerHTML = ''; box.style.display = 'none'; } } catch (_) { box.innerHTML = ''; box.style.display = 'none'; } } async function loadCrons(animate) { const box = $('cronList'); const refreshBtn = $('cronRefreshBtn'); loadCronGatewayNotice(); if (animate && refreshBtn) { refreshBtn.style.opacity = '0.5'; refreshBtn.disabled = true; } try { await loadCronProfiles(); const allProfilesQS = _showAllCronProfiles ? '?all_profiles=1' : ''; const data = await api('/api/crons' + allProfilesQS); _cronList = data.jobs || []; _cronOtherProfileCount = Number(data.other_profile_count || 0); if (_showAllCronProfiles && !_cronList.some(job => job && job.read_only)) { _showAllCronProfiles = false; _cronOtherProfileCount = 0; } box.innerHTML = ''; // Partition active vs paused so paused jobs don't drown the list (#4026). // _cronList stays the single source of truth — only the render is split, // which keeps openCronDetail, _cronNewJobIds, and detail refresh untouched. const _activeJobs = []; const _pausedJobs = []; for (const job of _cronList) { const status = _cronStatusMeta(job); (status.state === 'paused' ? _pausedJobs : _activeJobs).push({ job, status }); } const _appendCronItem = (parent, { job, status }) => { const item = document.createElement('div'); item.className = 'cron-item'; item.id = _cronItemId(job); if (job.read_only) { item.classList.add('readonly'); item.style.opacity = '0.78'; } const isNewRun = !job.read_only && _cronNewJobIds.has(String(job.id)); const isAgentMode = !job.no_agent; const ownerProfileLabel = _cronProfileLabel(_cronOwnerProfileName(job)); const ownerProfileTitle = `Owner profile: ${ownerProfileLabel}`; const readOnlyBadge = job.read_only ? 'Read-only' : ''; item.innerHTML = `
${isNewRun ? '' : ''} ${isAgentMode ? '🤖' : `📜`} ${esc(job.name)} ${esc(ownerProfileLabel)} ${esc(status.label)} ${readOnlyBadge}
`; item.onclick = () => openCronDetail(job, item); if (_currentCronDetailKey && _currentCronDetailKey === _cronJobKey(job)) item.classList.add('active'); parent.appendChild(item); }; if (!_cronList.length) { const emptyText = (!_showAllCronProfiles && _cronOtherProfileCount > 0) ? 'No cron jobs in the active profile.' : (t('cron_no_jobs') || 'No jobs yet'); box.innerHTML = `
${esc(emptyText)}
`; _appendCronProfileToggle(box); if (_cronMode !== 'create' && _cronMode !== 'edit') _clearCronDetail(); return; } for (const entry of _activeJobs) _appendCronItem(box, entry); if (_pausedJobs.length) { let collapsed = true; try { collapsed = localStorage.getItem('cron-paused-collapsed') !== '0'; } catch (_e) {} const details = document.createElement('details'); details.className = 'cron-paused-section'; if (!collapsed) details.open = true; const pausedLabel = t('cron_status_paused') || 'paused'; const headerLabel = pausedLabel.charAt(0).toUpperCase() + pausedLabel.slice(1); const summary = document.createElement('summary'); summary.className = 'cron-paused-summary'; summary.textContent = `${headerLabel} (${_pausedJobs.length})`; details.appendChild(summary); details.addEventListener('toggle', () => { try { localStorage.setItem('cron-paused-collapsed', details.open ? '0' : '1'); } catch (_e) {} }); const inner = document.createElement('div'); inner.className = 'cron-paused-inner'; details.appendChild(inner); for (const entry of _pausedJobs) _appendCronItem(inner, entry); box.appendChild(details); } _appendCronProfileToggle(box); // 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 => _cronJobKey(j) === _currentCronDetailKey); 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 _cronPanelExpandKey(jobId, suffix){ return `hermes-webui-cron-${suffix}-expanded-${encodeURIComponent(String(jobId||''))}`; } function _cronRunExpandKey(jobId, filename){ return `${_cronPanelExpandKey(jobId, 'run')}-${encodeURIComponent(String(filename||''))}`; } function _cronExpansionGet(key){ try { return localStorage.getItem(key) === '1'; } catch(_) { return false; } } function _cronExpansionSet(key, expanded){ try { localStorage.setItem(key, expanded ? '1' : '0'); } catch(_) {} } function toggleCronPromptExpanded(jobId){ const key = _cronPanelExpandKey(jobId, 'prompt'); _cronExpansionSet(key, !_cronExpansionGet(key)); if (_currentCronDetail && String(_currentCronDetail.id) === String(jobId)) { _renderCronDetail(_currentCronDetail); } } function toggleCronRunExpanded(jobId, filename, runId){ const key = _cronRunExpandKey(jobId, filename); const expanded = !_cronExpansionGet(key); _cronExpansionSet(key, expanded); const item = document.getElementById(runId); const body = item ? item.querySelector('.detail-run-body') : null; const btn = item ? item.querySelector('.detail-expand-toggle') : null; if (body) body.classList.toggle('expanded', expanded); if (btn) { btn.textContent = expanded ? '▴' : '▾'; btn.title = expanded ? (t('cron_collapse_output') || 'Collapse output') : (t('cron_expand_output') || 'Expand output'); btn.setAttribute('aria-label', btn.title); } } function _isCronScriptJob(job){ return !!(job && job.no_agent); } function _cronModeLabel(job){ return _isCronScriptJob(job) ? (t('cron_mode_script') || 'Script') : (t('cron_mode_agent') || 'Agent'); } function _cronOutputTitle(job){ return _isCronScriptJob(job) ? (t('cron_script_output') || 'Script output') : (t('cron_last_output') || 'Last output'); } function _cronScriptJobBannerHtml(){ return `
${esc(t('cron_mode_script') || 'Script')}

${esc(t('cron_script_job_banner') || 'Runs a script on schedule — stdout is delivered to the target. No agent, prompt, or skills.')}

`; } function _cronScriptCardHtml(job){ const script = String(job && job.script || '').trim() || '—'; const workdir = String(job && job.workdir || '').trim(); const workdirRow = workdir ? `
${esc(t('cron_workdir_label') || 'Working directory')}
${esc(workdir)}
` : ''; return `
${esc(t('cron_script_card_title') || 'Script')}
${esc(script)}
${workdirRow}
${esc(t('cron_script_path_hint') || 'Resolved under ~/.hermes/scripts/ unless an absolute path. Edit the script file on the server to change behavior.')}
`; } function _cronAgentPromptCardHtml(job){ const promptExpanded = _cronExpansionGet(_cronPanelExpandKey(job.id, 'prompt')); const promptToggleLabel = promptExpanded ? (t('cron_collapse_prompt') || 'Collapse prompt') : (t('cron_expand_prompt') || 'Expand prompt'); return `
${esc(t('cron_prompt_label') || 'Prompt')}
${esc(job.prompt || '')}
`; } function _renderCronDetail(job){ _currentCronDetail = job; _currentCronDetailKey = _cronJobKey(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 isNoAgent = _isCronScriptJob(job); const isReadOnly = !!job.read_only; const cronJobMode = _cronModeLabel(job); const modelProvider = job.provider && job.model ? `${esc(job.provider)}/${esc(job.model)}` : job.model ? esc(job.model) : job.provider ? esc(job.provider) : isNoAgent ? '' : esc(t('cron_model_use_default') || 'Use profile default'); const profileLabel = _cronProfileLabel(job.profile); const profileTitle = _cronProfileTitle(job.profile); const ownerProfileName = _cronOwnerProfileName(job); const ownerProfileLabel = _cronProfileLabel(ownerProfileName); const ownerProfileTitle = `Owner profile: ${ownerProfileLabel}`; const showOwnerRow = !!ownerProfileName && (isReadOnly || ownerProfileName !== _cronProfileName(job.profile)); 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 = !isReadOnly && attention ? `
${esc(t('cron_status_needs_attention'))}

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

${croniterHint}
` : ''; const readOnlyBanner = isReadOnly ? `
Read-only from another profile

Switch to ${esc(ownerProfileLabel)} to run, edit, or inspect live status and output for this cron job.

` : ''; const toastNotifications = job.toast_notifications !== false; const outputTitle = _cronOutputTitle(job); const skillsRow = isNoAgent ? '' : `
${esc(t('cron_skills_label') || 'Skills')}
${esc(skills)}
`; const instructionCard = isNoAgent ? _cronScriptCardHtml(job) : _cronAgentPromptCardHtml(job); body.innerHTML = `
${attentionBanner} ${readOnlyBanner} ${isNoAgent ? _cronScriptJobBannerHtml() : ''}
${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)}
${esc(t('cron_mode_label') || 'Mode')}
${esc(cronJobMode)}${modelProvider ? ` ${modelProvider}` : ''}
${showOwnerRow ? `
Owner profile
${esc(ownerProfileLabel)}
` : ''}
${esc(t('cron_profile_label') || 'Profile')}
${esc(profileLabel)}
${esc(t('cron_toast_notifications_label') || 'Completion toasts')}
${esc(toastNotifications ? (t('cron_toast_notifications_enabled') || 'Enabled') : (t('cron_toast_notifications_disabled') || 'Disabled'))}
${skillsRow} ${lastError}
${instructionCard}
${esc(outputTitle)}
${esc(isReadOnly ? 'Live output is available only when this profile is active here.' : (t('loading') || 'Loading'))}
`; body.style.display = ''; if (empty) empty.style.display = 'none'; _cronMode = 'read'; _setCronHeaderButtons('read', job); // Load runs asynchronously if (!isReadOnly) _loadCronDetailRuns(job.id, _currentCronDetailKey); } 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 header = $('mainTasks') && $('mainTasks').querySelector('.main-view-header'); const hide = b => b && (b.style.display = 'none'); const show = b => b && (b.style.display = ''); if (mode === 'read') { if (header) header.style.display = 'flex'; if (job && job.read_only) { [runBtn,pauseBtn,resumeBtn,editBtn,dupBtn,delBtn,cancelBtn,saveBtn].forEach(hide); return; } 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') { if (header) header.style.display = 'flex'; 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); if (header) header.style.display = 'none'; } } async function _loadCronDetailRuns(jobId, detailKey){ try { const data = await api(`/api/crons/history?job_id=${encodeURIComponent(jobId)}&limit=50`); if (!_cronDetailMatches(jobId, detailKey)) return; const card = $('cronDetailRuns'); if (!card) return; const outputTitle = _cronOutputTitle(_currentCronDetail); const isScriptJob = _isCronScriptJob(_currentCronDetail); if (!data.runs || !data.runs.length) { card.innerHTML = `
${esc(outputTitle)}
${esc(t('cron_no_runs_yet'))}
`; return; } const rows = data.runs.map((run, i) => { const ts = run.filename.replace('.md','').replace(/_/g,' '); const sizeStr = run.size > 1024 ? (run.size/1024).toFixed(1)+' KB' : run.size+' B'; const dateStr = new Date(run.modified * 1000).toLocaleString(); const rid = `cron-det-run-${jobId}-${i}`; const usageStrip = isScriptJob ? '' : _formatCronRunUsageStrip(run.usage); const runExpanded = _cronExpansionGet(_cronRunExpandKey(jobId, run.filename)); const runToggleLabel = runExpanded ? (t('cron_collapse_output') || 'Collapse output') : (t('cron_expand_output') || 'Expand output'); return `
${esc(ts)} ${esc(sizeStr)}${usageStrip ? ` ${esc(usageStrip)}` : ''} ▸
${esc(t('loading'))}
`; }).join(''); const countLabel = data.total > 50 ? ` (${data.total} runs, showing latest 50)` : ` (${data.total} runs)`; card.innerHTML = `
${esc(outputTitle)}${countLabel}
${rows}`; } catch(e) { /* ignore */ } } async function _loadRunContent(jobId, filename, runId){ const body = document.querySelector(`#${runId} .detail-run-body`); if (!body) return; const item = document.getElementById(runId); if (item.classList.contains('open')) { // Already open → collapse and return (toggle behaviour) item.classList.remove('open'); body.classList.remove('expanded'); _cronExpansionSet(_cronRunExpandKey(jobId, filename), false); const btn = item ? item.querySelector('.detail-expand-toggle') : null; if (btn) { btn.textContent = '▾'; btn.title = (t('cron_expand_output') || 'Expand output'); btn.setAttribute('aria-label', btn.title); } return; } item.classList.add('open'); body.classList.toggle('expanded', _cronExpansionGet(_cronRunExpandKey(jobId, filename))); body.innerHTML = `${esc(t('loading'))}`; try { const data = await api(`/api/crons/run?job_id=${encodeURIComponent(jobId)}&filename=${encodeURIComponent(filename)}`); if (data.error) { body.textContent = data.error; return; } const expanded = _cronExpansionGet(_cronRunExpandKey(jobId, filename)); const output = expanded ? (data.content || data.snippet || '') : (data.snippet || data.content || ''); body.classList.toggle('expanded', expanded); // Cron run output is never authored Markdown — render as literal // preformatted text using DOM-created
 so all content
    // (including shapes starting with #, |, >, ``` and embedded fences)
    // renders verbatim without Markdown interpretation.
    body.innerHTML = '';
    const pre = document.createElement('pre');
    pre.className = 'cron-run-pre';
    const code = document.createElement('code');
    code.textContent = output;
    pre.appendChild(code);
    body.appendChild(pre);
    const usageStrip = _formatCronRunUsageStrip(data.usage);
    if (usageStrip) {
      const usage = document.createElement('div');
      usage.className = 'cron-run-usage-strip cron-run-usage-footer';
      usage.textContent = usageStrip;
      body.appendChild(usage);
    }
    // Show "View full output" button only for collapsed previews. Expanded rows render the full body inline.
    if (!expanded && data.content && data.snippet && data.content.length > data.snippet.length) {
      const btn = document.createElement('button');
      btn.style.cssText = 'margin-top:8px;padding:4px 12px;border-radius:var(--radius-btn);border:1px solid var(--border-subtle);background:var(--surface-subtle);color:var(--text-secondary);cursor:pointer;font-size:12px';
      btn.textContent = t('cron_view_full_output') || 'View full output';
      btn.onclick = () => {
        _cronExpansionSet(_cronRunExpandKey(jobId, filename), true);
        body.classList.add('expanded');
        body.innerHTML = '';
        const pre = document.createElement('pre');
        pre.className = 'cron-run-pre';
        const code = document.createElement('code');
        code.textContent = data.content || '';
        pre.appendChild(code);
        body.appendChild(pre);
        const usageStrip = _formatCronRunUsageStrip(data.usage);
        if (usageStrip) {
          const usage = document.createElement('div');
          usage.className = 'cron-run-usage-strip cron-run-usage-footer';
          usage.textContent = usageStrip;
          body.appendChild(usage);
        }
        btn.remove();
      };
      body.appendChild(btn);
    }
  } catch(e) {
    body.textContent = 'Error: ' + e.message;
  }
}

function openCronDetail(jobOrId, el){
  const job = _findCronJob(jobOrId);
  if (!job) return;
  const detailKey = _cronJobKey(job);
  document.querySelectorAll('.cron-item').forEach(e => e.classList.remove('active'));
  const target = el || $(_cronItemId(job));
  if (target) target.classList.add('active');
  // Remove new-run dot from this job since user is now viewing it
  if (!job.read_only) _clearCronUnreadForJob(job.id);
  const dot = target && target.querySelector('.cron-new-dot');
  if (dot && !job.read_only) dot.remove();
  _cronPreFormDetail = null;
  _editingCronId = null;
  _stopCronWatch();
  _renderCronDetail(job);
  if (!job.read_only) _checkCronWatchOnDetail(job.id, detailKey);
  _closeMobileSidebarAfterPanelSelection();
}

function _clearCronDetail(){
  _currentCronDetail = null;
  _currentCronDetailKey = '';
  _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 _cronScheduleForEdit(job){
  // #7352: when the user opens an existing job for edit or duplicate, the
  // editable field must hold the canonical Agent-parseable schedule, not
  // the human-readable ``schedule_display`` ("once at ..."). The Agent
  // parser at cron/jobs.py rejects the display form with ValueError and
  // the resulting round-trip has been failing with HTTP 500 since the
  // initial WebUI release.
  if(!job) return '';
  const sched = job.schedule;
  // Only the "once at ..." label is unparseable; every other schedule_display
  // form the Agent emits (`every monday 9am`, `every 30m`, ...) round-trips
  // through parse_schedule() AND is what the user actually typed. Prefer it
  // over the canonical `expr`, otherwise editing or duplicating a
  // natural-language recurring job silently rewrites it to raw cron
  // (`every monday 9am` -> `0 9 * * 1`) — the Agent rebuilds schedule_display
  // from whatever we submit, so the rewrite sticks.
  const display = job.schedule_display;
  const displayIsParseable = Boolean(display) && !/^\s*once at\s+/i.test(display);
  if(sched && typeof sched === 'object'){
    // One-shot: schedule_display is purely presentation; run_at is what the
    // parser accepts.
    if(sched.kind === 'once' && sched.run_at) return sched.run_at;
    if(displayIsParseable) return display;
    // Recurring cron: the current Agent schema is ``expr``; some legacy
    // payloads still expose ``expression`` — accept either.
    if(sched.expr) return sched.expr;
    if(sched.expression) return sched.expression;
    if(sched.run_at) return sched.run_at;
  }
  // Final fallback: only use schedule_display if it isn't the "once at ..."
  // presentation label (which the parser rejects). For any other text
  // (e.g. interval/every-30m) the display form is also a valid input.
  if(displayIsParseable){
    return display;
  }
  return '';
}

function duplicateCurrentCron(){
  if (!_currentCronDetail) return;
  const job = _currentCronDetail;
  if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
  _cronPreFormDetail = { ...job };
  _cronModelPickerTouched = false;
  _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: _cronScheduleForEdit(job),
    prompt: job.prompt || '',
    deliver: job.deliver || 'local',
    profile: job.profile || '',
    toast_notifications: job.toast_notifications !== false,
    no_agent: !!job.no_agent,
    script: job.script || '',
    model: job.model || '',
    provider: job.provider || '',
    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;
let _cronProfilesCache=null;
let _cronDeliveryOptionsCache=null;

function openCronCreate(){
  if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
  _cronPreFormDetail = _currentCronDetail ? { ..._currentCronDetail } : null;
  _cronModelPickerTouched = false;
  _editingCronId = null;
  _cronMode = 'create';
  _cronIsDuplicate = false;
  _cronSelectedSkills = [];
  _renderCronForm({ name:'', schedule:'0 9 * * *', prompt:'', deliver:'local', profile:'', toast_notifications:true, model:'', provider:'', isEdit:false });
  _cronSkillsCache = null;
  api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{});
  loadCronProfiles().then(()=>_refreshCronProfileSelect('')).catch(()=>{});
  // Mobile: the cron form lives in the main view, which is covered by the
  // full-screen sidebar drawer. Close the drawer so the form is visible (mirror
  // openCronDetail's behaviour); no-op on desktop.
  _closeMobileSidebarAfterPanelSelection();
}

function openCronEdit(job){
  if (!job) return;
  _cronPreFormDetail = { ...job };
  _cronModelPickerTouched = false;
  _editingCronId = job.id;
  _cronMode = 'edit';
  _cronSelectedSkills = Array.isArray(job.skills) ? [...job.skills] : [];
  _renderCronForm({
    name: job.name || '',
    schedule: _cronScheduleForEdit(job),
    prompt: job.prompt || '',
    deliver: job.deliver || 'local',
    profile: job.profile || '',
    toast_notifications: job.toast_notifications !== false,
    no_agent: !!job.no_agent,
    script: job.script || '',
    model: job.model || '',
    provider: job.provider || '',
    isEdit: true,
  });
  if (!_cronSkillsCache) {
    api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{});
  } else {
    _bindCronSkillPicker();
  }
  loadCronProfiles().then(()=>_refreshCronProfileSelect(job.profile || '')).catch(()=>{});
}

function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notifications=true, no_agent=false, script='', model='', provider='', isEdit }){
  const title = $('taskDetailTitle');
  const body = $('taskDetailBody');
  const empty = $('taskDetailEmpty');
  if (!body || !title) return;
  const isNoAgent = !!no_agent;
  const toastNotifications = toast_notifications !== false;
  title.textContent = isEdit ? (t('edit') + ' · ' + (name || schedule || t('scheduled_jobs'))) : t('new_job');
  const promptBlock = isNoAgent ? '' : `
        
`; const scriptBlock = isNoAgent ? `
${esc(t('cron_script_path_hint') || 'Resolved under ~/.hermes/scripts/ unless an absolute path. Edit the script file on the server to change behavior.')}
` : ''; const skillsBlock = isNoAgent ? '' : `
${isEdit ? `
${esc(t('cron_skills_edit_hint') || 'Skill list is not editable after creation.')}
` : ''}
`; body.innerHTML = `
${isNoAgent ? _cronScriptJobBannerHtml() : ''}
${scriptBlock} ${promptBlock}
${esc(t('cron_profile_server_default_hint') || 'Uses the WebUI server default profile at run time')}
${esc(isNoAgent ? (t('cron_model_no_agent_hint') || 'No-agent jobs run the configured script directly; model is unused.') : (t('cron_model_hint') || 'Use the profile default model at run time, or pin this job to a specific provider/model.'))}
${skillsBlock}
`; body.style.display = ''; if (empty) empty.style.display = 'none'; _setCronHeaderButtons(isEdit ? 'edit' : 'create'); _populateCronDeliverOptions(deliver, isEdit); _populateCronFormModelSelect(model, provider, isNoAgent); if (!isNoAgent) _renderCronSkillTags(); _initCronSchedulePresetControls(); const focusEl = $('cronFormName'); if (focusEl) focusEl.focus(); } async function _populateCronDeliverOptions(selectedValue, isEdit) { var sel = $('cronFormDeliver'); if (!sel) return; sel.disabled = true; try { if (!_cronDeliveryOptionsCache) { var res = await api('/api/crons/delivery-options'); _cronDeliveryOptionsCache = res && res.platforms ? res.platforms : []; } sel.innerHTML = ''; for (var i = 0; i < _cronDeliveryOptionsCache.length; i++) { var p = _cronDeliveryOptionsCache[i]; var opt = document.createElement('option'); opt.value = p.value; opt.textContent = p.label; if (p.value === selectedValue) opt.selected = true; sel.appendChild(opt); } if (selectedValue && !sel.querySelector('option[value="' + CSS.escape(selectedValue) + '"]')) { var opt = document.createElement('option'); opt.value = selectedValue; opt.textContent = selectedValue + ' *'; opt.selected = true; sel.prepend(opt); } } catch (e) { sel.innerHTML = ''; } sel.disabled = false; } async function _populateCronFormModelSelect(selectedModel, selectedProvider, disabled){ const sel = $('cronFormModel'); if (!sel) return; delete sel.dataset.loaded; sel.disabled = true; sel.innerHTML = ``; try { const data = await api('/api/models'); const groups = (Array.isArray(data && data.groups) && data.groups.length) ? data.groups : []; for (const g of groups) { const og = document.createElement('optgroup'); og.label = g.provider || g.provider_id || 'Configured'; if (g.provider_id) og.dataset.provider = g.provider_id; for (const m of [...(Array.isArray(g.models) ? g.models : []), ...(Array.isArray(g.extra_models) ? g.extra_models : [])]) { if (!m || !m.id) continue; const opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.label || m.id; if (g.provider_id) opt.dataset.provider = g.provider_id; og.appendChild(opt); } if (og.children.length) sel.appendChild(og); } let found = false; if (selectedModel) { if (typeof _applyModelToDropdown === 'function') { found = !!_applyModelToDropdown(selectedModel, sel, selectedProvider || null); } if (!found) { for (const opt of sel.options) { if (opt.value !== selectedModel) continue; const prov = opt.dataset.provider || (opt.parentElement && opt.parentElement.dataset.provider) || ''; if (!selectedProvider || prov === selectedProvider) { opt.selected = true; found = true; break; } } } } else { found = true; } if (selectedModel && !found) { const opt = document.createElement('option'); opt.value = selectedModel; opt.textContent = `${selectedModel} (${t('not_available') || 'not available'})`; if (selectedProvider) opt.dataset.provider = selectedProvider; opt.selected = true; sel.appendChild(opt); } sel.dataset.loaded = '1'; // Track deliberate picker changes so an untouched "Default" can keep a // hidden provider-only pin on save (see _cronProviderForClear). sel.addEventListener('change', () => { _cronModelPickerTouched = true; }); } catch (e) { console.warn('Failed to load cron model picker:', e.message); // Load failed: dataset.loaded stays unset so saveCronForm omits model/provider // and preserves any existing override. Keep the select DISABLED rather than // re-enabling it showing only "Default" — an enabled "Default"-only select // would let the user think they cleared the override when a save actually // preserves it (Opus advisor, stage-345). A reopen retries the load. sel.disabled = true; return; } sel.disabled = !!disabled; } 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(); } function _modelBareNameForProvider(model, provider) { // Strip @provider: prefix from a model value when provider is stored separately. if (model && provider && model.startsWith('@' + provider + ':')) { return model.slice(('@' + provider + ':').length); } return model; } function _cronModelBareName(model, provider) { // Cron jobs store model and provider separately, just like auxiliary slots. return _modelBareNameForProvider(model, provider); } function _cronProviderForClear(prevDetail, pickerTouched) { // Provider to submit when the picker shows "Default" (no model) on save. // An explicit return to Default honors "Default = no overrides" (#4030) and // clears the provider. An untouched picker must not: provider-only jobs // (a pinned provider with no model) render as "Default" in the combined // picker, so saving any other field would silently erase the pin the user // never touched. const prev = prevDetail || {}; if (pickerTouched) return null; return (prev.model == null) ? prev.provider : null; } async function saveCronForm(){ const nameEl=$('cronFormName'); const schEl=$('cronFormSchedule'); const promptEl=$('cronFormPrompt'); const delivEl=$('cronFormDeliver'); const profileEl=$('cronFormProfile'); const toastEl=$('cronFormToastNotifications'); const errEl=$('cronFormError'); if(!schEl||!errEl) return; const isNoAgent = !!(_cronPreFormDetail && _cronPreFormDetail.no_agent); if(!isNoAgent && !promptEl) return; const name=(nameEl?nameEl.value:'').trim(); const schedule=schEl.value.trim(); const prompt=promptEl ? promptEl.value.trim() : ''; const deliver=delivEl?delivEl.value:'local'; const profile=profileEl?profileEl.value:''; const toastNotifications=toastEl?!!toastEl.checked:true; errEl.style.display='none'; if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;} if(!isNoAgent && !prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;} try{ const modelEl = $('cronFormModel'); const modelLoaded = !!(modelEl && modelEl.dataset.loaded === '1'); const selectedModel = modelEl ? (modelEl.value || '').trim() : ''; if (_editingCronId) { const updates = {job_id: _editingCronId, schedule, profile: profile, toast_notifications: toastNotifications}; if (!isNoAgent) updates.prompt = prompt; if (name) updates.name = name; if (deliver) updates.deliver = deliver; if (modelEl) { if (selectedModel && modelLoaded) { const modelState = (typeof _modelStateForSelect === 'function') ? _modelStateForSelect(modelEl, selectedModel) : { model: selectedModel, model_provider: null }; updates.model = _cronModelBareName(modelState.model, modelState.model_provider) || null; updates.provider = modelState.model_provider || null; } else if (modelLoaded) { // "Default" selected (no model). An untouched picker preserves a // hidden provider-only pin; a deliberate return to Default clears // it (#4030 "Default = no overrides"). updates.model = null; updates.provider = _cronProviderForClear(_cronPreFormDetail, _cronModelPickerTouched); } // else: select not yet populated — omit model/provider to preserve saved value } 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(job); return; } const body={schedule,prompt,deliver,profile: profile, toast_notifications: toastNotifications}; if(_cronIsDuplicate) body.enabled=false; if(name)body.name=name; if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills; if (modelEl && modelLoaded) { if (selectedModel) { const modelState = (typeof _modelStateForSelect === 'function') ? _modelStateForSelect(modelEl, selectedModel) : { model: selectedModel, model_provider: null }; body.model = _cronModelBareName(modelState.model, modelState.model_provider) || null; body.provider = modelState.model_provider || null; } } else if (_cronIsDuplicate && _cronPreFormDetail && _cronPreFormDetail.model) { body.model = _cronModelBareName(_cronPreFormDetail.model, _cronPreFormDetail.provider) || null; body.provider = _cronPreFormDetail.provider || null; } 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]); }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)'; } function _formatCronRunUsageStrip(usage) { if (!usage || typeof usage !== 'object') return ''; const parts = []; const fmt = n => { const value = Number(n || 0); if (!Number.isFinite(value) || value <= 0) return ''; if (value >= 1000000) return (value / 1000000).toFixed(value >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M'; if (value >= 1000) return (value / 1000).toFixed(value >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k'; return String(Math.round(value)); }; const input = fmt(usage.input_tokens); const output = fmt(usage.output_tokens); const total = fmt(usage.total_tokens); if (input || output) parts.push(`${input || '0'} in · ${output || '0'} out`); else if (total) parts.push(`${total} tokens`); const cost = Number(usage.estimated_cost_usd); if (Number.isFinite(cost) && cost > 0) parts.push(`$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`); if (usage.model) parts.push(String(usage.model)); return parts.join(' · '); } // ── Cron run watch ──────────────────────────────────────────────────────────── let _cronWatchInterval = null; let _cronWatchStart = null; let _cronWatchTimerInterval = null; function _startCronWatch(jobId, detailKey) { _stopCronWatch(); _cronWatchStart = Date.now(); _cronWatchInterval = setInterval(async () => { try { const data = await api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false}); if (!data.running) { _stopCronWatch(); if (_cronDetailMatches(jobId, detailKey)) { _loadCronDetailRuns(jobId, detailKey); } return; } // Still running — update elapsed if (_cronDetailMatches(jobId, detailKey)) { 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 (_cronDetailMatches(jobId, detailKey) && _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 (_cronDetailMatches(jobId, detailKey)) { _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, detailKey) { // When opening a detail view, check if job is running api(`/api/crons/status?job_id=${encodeURIComponent(jobId)}`,{timeoutToast:false}).then(data => { if (data.running && _cronDetailMatches(jobId, detailKey)) { _startCronWatch(jobId, detailKey); } }).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, _currentCronDetailKey); } 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; // ── Kanban panel (read-only) ── function _kanbanColumnLabel(name){ return t('kanban_status_' + name) || name; } function _kanbanTaskTitle(task){ return task.title || task.summary || task.id || t('kanban_task'); } function _kanbanTaskBody(task){ return task.body || task.description || task.prompt || ''; } function _kanbanTaskMeta(task){ const bits = []; bits.push(task.assignee ? task.assignee : t('kanban_unassigned')); if (task.tenant) bits.push(task.tenant); if (task.priority !== undefined && task.priority !== null) bits.push('P' + task.priority); if (task.comment_count) bits.push('💬 ' + task.comment_count); if (task.link_counts && task.link_counts.children) bits.push('↳ ' + task.link_counts.children); return bits; } function _kanbanCurrentFilters(){ const q = $('kanbanSearch') ? $('kanbanSearch').value.trim().toLowerCase() : ''; const assigneeEl = $('kanbanAssigneeFilter'); const tenantEl = $('kanbanTenantFilter'); const assignee = assigneeEl ? (assigneeEl.value || assigneeEl.dataset.defaultValue || '') : ''; const tenant = tenantEl ? (tenantEl.value || tenantEl.dataset.defaultValue || '') : ''; const includeArchived = !!($('kanbanIncludeArchived') && $('kanbanIncludeArchived').checked); const onlyMine = !!($('kanbanOnlyMine') && $('kanbanOnlyMine').checked); return {q, assignee, tenant, includeArchived, onlyMine}; } function _kanbanApplyConfigDefaults(config){ if (!config) return; _kanbanLanesByProfile = config.lane_by_profile === true; syncKanbanViewToggle(); if (_kanbanConfigApplied) return; if ($('kanbanTenantFilter') && config.default_tenant) $('kanbanTenantFilter').dataset.defaultValue = config.default_tenant; if ($('kanbanIncludeArchived') && config.include_archived_by_default === true) $('kanbanIncludeArchived').checked = true; _kanbanConfigApplied = true; } let _kanbanConfigApplied = false; function syncKanbanViewToggle(){ const btn = $('btnKanbanViewToggle'); if (!btn) return; const consolidated = !_kanbanLanesByProfile; const label = t('kanban_view_consolidated'); btn.setAttribute('aria-pressed', consolidated ? 'true' : 'false'); btn.setAttribute('aria-label', label); btn.setAttribute('data-i18n-title', 'kanban_view_consolidated'); btn.setAttribute('data-i18n-aria-label', 'kanban_view_consolidated'); if (typeof _setButtonTooltip === 'function') _setButtonTooltip(btn, label); else btn.setAttribute('data-tooltip', label); } async function toggleKanbanViewMode(){ const btn = $('btnKanbanViewToggle'); const nextLaneByProfile = !_kanbanLanesByProfile; if (btn) btn.disabled = true; try { const saved = await api('/api/kanban/config', {method: 'PATCH', body: JSON.stringify({lane_by_profile: nextLaneByProfile})}); _kanbanLanesByProfile = saved.lane_by_profile === true; syncKanbanViewToggle(); _kanbanRenderBoard(); showToast(t(_kanbanLanesByProfile ? 'kanban_view_lanes_saved' : 'kanban_view_consolidated_saved')); } catch(e) { showToast(t('kanban_view_update_failed') + (e.message || e), 4000, 'error'); } finally { if (btn) btn.disabled = false; } } function _kanbanSetSelectOptions(el, values, allLabelKey){ if (!el) return; const current = el.value || el.dataset.defaultValue || ''; const opts = [``] .concat((values || []).map(v => ``)); el.innerHTML = opts.join(''); if ([...el.options].some(o => o.value === current)) el.value = current; } function _kanbanVisibleTasks(){ const filters = _kanbanCurrentFilters(); const columns = (_kanbanBoard && _kanbanBoard.columns) || []; return columns.map(col => { const tasks = (col.tasks || []).filter(task => { if (!filters.q) return true; const haystack = [task.id, _kanbanTaskTitle(task), _kanbanTaskBody(task), task.assignee, task.tenant] .filter(Boolean).join(' ').toLowerCase(); return haystack.includes(filters.q); }); return {...col, tasks}; }); } function _kanbanRenderSidebar(columns){ const list = $('kanbanList'); if (!list) return; const tasks = columns.flatMap(col => (col.tasks || []).map(task => ({...task, status: task.status || col.name}))); if (!tasks.length) { list.innerHTML = `
${esc(t('kanban_no_matching_tasks'))}
`; return; } list.innerHTML = tasks.map(task => { const meta = _kanbanTaskMeta(task); return ``; }).join(''); } /** * Render inline markdown (bold, italic, code, links, strikethrough). * Input is already HTML-escaped. */ function _kanbanRenderMarkdownInline(escaped){ return String(escaped || '') .replace(/~~([^~\n]+)~~/g, (_m, text) => `${text}`) .replace(/`([^`\n]+)`/g, (_m, code) => `${code}`) .replace(/\*\*([^*\n]+)\*\*/g, (_m, text) => `${text}`) .replace(/(^|[^*a-zA-Z0-9])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}${text}`) .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, (_m, text, href) => `${text}`); } /** * Render full markdown block content: headings, code blocks, lists, tables, * task lists, blockquotes, horizontal rules, paragraphs + inline formatting. */ function _kanbanRenderMarkdown(source){ if (!source) return ''; const lines = esc(source).split(/\r?\n/); const out = []; let i = 0; while (i < lines.length) { const line = lines[i]; const trimmed = line.trim(); // ── Code block ── if (/^```/.test(trimmed)) { const lang = trimmed.slice(3).trim(); const codeLines = []; i++; while (i < lines.length && !/^```/.test(lines[i].trim())) { codeLines.push(lines[i]); i++; } i++; // skip closing ``` const codeHtml = codeLines.join('\n'); out.push(lang ? `
${codeHtml}
` : `
${codeHtml}
`); continue; } // ── Horizontal rule ── if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) { out.push('
'); i++; continue; } // ── Heading ── const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { const level = headingMatch[1].length; out.push(`${_kanbanRenderMarkdownInline(headingMatch[2])}`); i++; continue; } // ── Blockquote ── if (/^>\s?/.test(trimmed)) { const quoteLines = []; while (i < lines.length && /^>\s?/.test(lines[i].trim())) { quoteLines.push(lines[i].trim().replace(/^>\s?/, '')); i++; } out.push(`
${_kanbanRenderMarkdownInline(quoteLines.join('
'))}
`); continue; } // ── Table row ── if (/^\|.+\|$/.test(trimmed)) { const tableRows = []; const tableAligns = []; while (i < lines.length && /^\|.+\|$/.test(lines[i].trim())) { const row = lines[i].trim(); // Detect alignment separator row if (/^\|[\s:]*-{3,}[\s:]*\|/.test(row)) { const cells = row.split('|').filter(c => c.trim().length > 0); cells.forEach(c => { const t = c.trim(); if (t.startsWith(':') && t.endsWith(':')) tableAligns.push('center'); else if (t.endsWith(':')) tableAligns.push('right'); else tableAligns.push('left'); }); } else { const cells = row.split('|').filter(c => c.trim().length > 0); tableRows.push(cells.map((c, ci) => { const align = tableAligns[ci] ? ` style="text-align:${tableAligns[ci]}"` : ''; return `${_kanbanRenderMarkdownInline(c.trim())}`; }).join('')); } i++; } if (tableRows.length) { out.push(`${tableRows.map(r => `${r}`).join('')}
`); } continue; } // ── Task list item ── const taskMatch = trimmed.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/); if (taskMatch) { const checked = taskMatch[1] !== ' '; const text = taskMatch[2]; const items = []; items.push(`
  • ${_kanbanRenderMarkdownInline(text)}
  • `); i++; // Collect continuation items while (i < lines.length) { const next = lines[i].trim(); const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/); const nextLi = next.match(/^[-*+]\s+(.+)$/); if (nextTask) { const c = nextTask[1] !== ' '; items.push(`
  • ${_kanbanRenderMarkdownInline(nextTask[2])}
  • `); i++; } else if (nextLi) { items.push(`
  • ${_kanbanRenderMarkdownInline(nextLi[1])}
  • `); i++; } else { break; } } out.push(`
      ${items.join('')}
    `); continue; } // ── Unordered list item ── const ulMatch = trimmed.match(/^[-*+]\s+(.+)$/); if (ulMatch) { const items = []; items.push(`
  • ${_kanbanRenderMarkdownInline(ulMatch[1])}
  • `); i++; while (i < lines.length) { const next = lines[i].trim(); const nextUl = next.match(/^[-*+]\s+(.+)$/); const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/); if (nextTask) break; // let task list handler get it if (nextUl) { items.push(`
  • ${_kanbanRenderMarkdownInline(nextUl[1])}
  • `); i++; } else { break; } } out.push(`
      ${items.join('')}
    `); continue; } // ── Ordered list item ── const olMatch = trimmed.match(/^\d+\.\s+(.+)$/); if (olMatch) { const items = []; items.push(`
  • ${_kanbanRenderMarkdownInline(olMatch[1])}
  • `); i++; while (i < lines.length) { const next = lines[i].trim(); const nextOl = next.match(/^\d+\.\s+(.+)$/); if (nextOl) { items.push(`
  • ${_kanbanRenderMarkdownInline(nextOl[1])}
  • `); i++; } else { break; } } out.push(`
      ${items.join('')}
    `); continue; } // ── Empty line ── if (!trimmed) { out.push(''); i++; continue; } // ── Paragraph ── out.push(`

    ${_kanbanRenderMarkdownInline(trimmed)}

    `); i++; } return `
    ${out.join('\n')}
    `; } function _kanbanFormatDuration(seconds){ const n = Number(seconds); if (!Number.isFinite(n) || n <= 0) return ''; if (n < 60) return Math.round(n) + 's'; if (n < 3600) return Math.round(n / 60) + 'm'; if (n < 86400) return Math.round(n / 3600) + 'h'; return Math.round(n / 86400) + 'd'; } function _kanbanTaskAge(task){ const age = task && (task.age_seconds || task.age); if (Number.isFinite(Number(age))) return _kanbanFormatDuration(age); return ''; } function _kanbanCardStalenessClass(task){ const age = Number(task && (task.age_seconds || task.age)); const status = task && task.status; if (!Number.isFinite(age)) return ''; if ((status === 'running' && age > 3600) || (status === 'blocked' && age > 86400)) return 'kanban-card-stale-red'; if ((status === 'running' && age > 600) || (status === 'ready' && age > 3600) || (status === 'blocked' && age > 3600)) return 'kanban-card-stale-amber'; return ''; } function _kanbanCardQuickActions(task){ const status = task.status || ''; const complete = status !== 'done' && status !== 'archived' ? `` : ''; const archive = status !== 'archived' ? `` : ''; return `
    ${complete}${archive}
    `; } async function quickKanbanCardAction(event, taskId, status){ if (event) event.stopPropagation(); return updateKanbanTask(taskId, {status}); } function _kanbanSuppressNextCardClick(){ _kanbanSuppressCardClickUntil = Date.now() + 700; } function dragKanbanTask(event, taskId){ _kanbanSuppressNextCardClick(); if (!event.dataTransfer) return; event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', taskId); } function finishKanbanDrag(event){ if (event) _kanbanSuppressNextCardClick(); } function openKanbanCard(event, taskId){ if (Date.now() < _kanbanSuppressCardClickUntil) { if (event) { event.preventDefault(); event.stopPropagation(); } return false; } loadKanbanTask(taskId); return false; } function allowKanbanDrop(event){ // Don't accept drops into the 'running' column. Entering 'running' is owned // by the dispatcher/claim_task path (sets claim_lock + claim_expires + // started_at + worker_pid). A drag-drop would bypass that contract and the // bridge would reject the resulting PATCH with HTTP 400 anyway. Refuse the // drop visually so users see immediate feedback. const target = event.currentTarget; if (target && target.dataset && target.dataset.kanbanStatus === 'running') { if (event.dataTransfer) event.dataTransfer.dropEffect = 'none'; return; } event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; } function clearKanbanDrop(event){ if (event && event.currentTarget) event.currentTarget.classList.remove('drop-target'); } async function dropKanbanTask(event, status){ _kanbanSuppressNextCardClick(); event.preventDefault(); event.stopPropagation(); clearKanbanDrop(event); const taskId = event.dataTransfer ? event.dataTransfer.getData('text/plain') : ''; if (taskId && status) await updateKanbanTask(taskId, {status}, {openDetail: false}); _kanbanSuppressNextCardClick(); } const KANBAN_UNASSIGNED_LANE = '__unassigned__'; function _kanbanLaneKey(task){ return task && task.assignee ? String(task.assignee) : KANBAN_UNASSIGNED_LANE; } function _kanbanLaneLabel(lane){ return lane === KANBAN_UNASSIGNED_LANE ? t('kanban_unassigned') : lane; } function _kanbanLaneNames(columns){ const names = new Set(); columns.forEach(col => (col.tasks || []).forEach(task => names.add(_kanbanLaneKey(task)))); const assigned = Array.from(names).filter(n => n !== KANBAN_UNASSIGNED_LANE).sort((a, b) => { if (a === 'default') return -1; if (b === 'default') return 1; return String(a).localeCompare(String(b)); }); if (names.has(KANBAN_UNASSIGNED_LANE)) assigned.push(KANBAN_UNASSIGNED_LANE); return assigned; } function _kanbanRenderColumn(col){ const tasks = col.tasks || []; return `
    ${esc(_kanbanColumnLabel(col.name))} ${tasks.length}
    ${tasks.length ? tasks.map(task => _kanbanCard(task, col.name)).join('') : `
    ${esc(t('kanban_empty'))}
    `}
    `; } function _kanbanRenderProfileLanes(columns){ const lanes = _kanbanLaneNames(columns); if (!lanes.length) return columns.map(_kanbanRenderColumn).join(''); return `
    ${lanes.map(lane => { const laneCols = columns.map(col => ({...col, tasks: (col.tasks || []).filter(task => _kanbanLaneKey(task) === lane)})); const count = laneCols.reduce((sum, col) => sum + (col.tasks || []).length, 0); const laneClass = lane === KANBAN_UNASSIGNED_LANE ? ' kanban-profile-lane-unassigned' : ''; return `
    ${esc(_kanbanLaneLabel(lane))}${count}
    ${laneCols.map(_kanbanRenderColumn).join('')}
    `; }).join('')}
    `; } function _kanbanEmptyBoardHtml(){ return `
    ${esc(t('kanban_no_data'))}
    ${esc(t('kanban_work_queue_hint'))}
    `; } function _kanbanHiddenByFiltersHtml(){ return `
    ${esc(t('kanban_tasks_hidden_by_filters'))}
    `; } function clearKanbanFilters(){ const s = $('kanbanSearch'); if (s) s.value = ''; const a = $('kanbanAssigneeFilter'); if (a) { a.value = ''; a.dataset.defaultValue = ''; } const te = $('kanbanTenantFilter'); if (te) { te.value = ''; te.dataset.defaultValue = ''; } const ai = $('kanbanIncludeArchived'); if (ai) ai.checked = false; const om = $('kanbanOnlyMine'); if (om) om.checked = false; loadKanban(true); } function _kanbanRenderBoard(){ const board = $('kanbanBoard'); if (!board) return; if (!_kanbanBoard || !_kanbanBoard.columns) { board.innerHTML = _kanbanEmptyBoardHtml(); return; } board.classList.toggle('kanban-board-consolidated', !_kanbanLanesByProfile); const columns = _kanbanVisibleTasks(); const total = columns.reduce((n, col) => n + (col.tasks || []).length, 0); if ($('kanbanSummary')) $('kanbanSummary').textContent = String(t('kanban_visible_tasks')).replace('{0}', total); _kanbanRenderSidebar(columns); if (total === 0) { const unfilteredTotal = (_kanbanBoard.columns || []).reduce((n, col) => n + (col.tasks || []).length, 0); board.innerHTML = unfilteredTotal > 0 ? _kanbanHiddenByFiltersHtml() : _kanbanEmptyBoardHtml(); return; } board.innerHTML = _kanbanLanesByProfile ? _kanbanRenderProfileLanes(columns) : columns.map(_kanbanRenderColumn).join(''); } function _kanbanCard(task, status){ const priority = Number(task.priority || 0); const links = task.link_counts || {}; const linkTotal = Number(links.parents || 0) + Number(links.children || 0); const comments = Number(task.comment_count || 0); const age = _kanbanTaskAge(task); const stale = _kanbanCardStalenessClass(task); const body = _kanbanTaskBody(task); const assignee = task.assignee ? `@${esc(task.assignee)}` : `${esc(t('kanban_unassigned'))}`; return `
    ${esc(task.id || '')}${priority ? `P${priority}` : ''}${task.tenant ? `${esc(task.tenant)}` : ''}
    ${esc(_kanbanTaskTitle(task))}
    ${body ? `
    ${_kanbanRenderMarkdown(body)}
    ` : ''}
    ${assignee}${comments ? `💬 ${comments}` : ''}${linkTotal ? `↔ ${linkTotal}` : ''}${age ? `${esc(age)}` : ''}
    ${_kanbanCardQuickActions(task)}
    `; } async function hardRefreshWebUIClient(){ try { if (navigator.serviceWorker) { const regs = await navigator.serviceWorker.getRegistrations(); await Promise.all(regs.map(r => r.unregister())); } } catch(_) {} try { if (window.caches) { const keys = await caches.keys(); await Promise.all(keys.map(k => caches.delete(k))); } } catch(_) {} window.location.reload(); } function _normalizeWebUIVersion(value){ if(!value) return ''; const s=String(value).trim(); if(!s) return ''; // Suppress placeholder / non-version sentinels (case-insensitive) so a real // client version never "mismatches" against a server that couldn't detect its // own version. api/updates.py can emit 'unknown' (git describe failure in a // Docker/CI image); comparing a real version against 'unknown' would FALSELY // fire the stale-client banner. (Codex #5480 gate) const lower=s.toLowerCase(); if(lower==='__webui_version__'||lower==='not detected'||lower==='unknown') return ''; return s; } function _currentWebUIBundleVersion(){ try{ const raw=window.__HERMES_WEBUI_BUNDLE_VERSION__; if(!raw) return ''; let s=String(raw); try{ s=decodeURIComponent(s.replace(/\+/g,' ')); }catch(_){} return _normalizeWebUIVersion(s); }catch(_){ return ''; } } function _showStaleWebUIClientBanner(clientVersion,serverVersion){ const banner=document.getElementById('staleClientBanner'); if(!banner) return; const msg=document.getElementById('staleClientMessage'); const versions=document.getElementById('staleClientVersions'); if(msg) msg.textContent='This tab is running a different WebUI version. Hard refresh to restore full functionality.'; if(versions) versions.textContent='Running: '+clientVersion+' → Server: '+serverVersion; banner.style.display='flex'; } function checkWebUIVersionSkew(settings){ try{ if(!settings) return; const client=_currentWebUIBundleVersion(); const server=_normalizeWebUIVersion(settings.webui_version); if(!client||!server) return; if(client===server) return; _showStaleWebUIClientBanner(client,server); }catch(_){} } window.checkWebUIVersionSkew=checkWebUIVersionSkew; function _startWebUIVersionSkewMonitor(){ let _pollTimer=null; function _isBannerVisible(){ const banner=document.getElementById('staleClientBanner'); return !!(banner&&banner.style.display==='flex'); } function _check(){ if(_isBannerVisible()) return; Promise.resolve().then(function(){ return api('/api/settings'); }).then(function(s){ checkWebUIVersionSkew(s); }).catch(function(){}); } function _startPoll(){ if(_pollTimer||document.hidden) return; _pollTimer=setInterval(function(){ if(document.hidden){ clearInterval(_pollTimer); _pollTimer=null; return; } if(_isBannerVisible()){ clearInterval(_pollTimer); _pollTimer=null; return; } _check(); },60000); } _check(); document.addEventListener('visibilitychange',function(){ if(!document.hidden){ _check(); _startPoll(); } else if(_pollTimer){ clearInterval(_pollTimer); _pollTimer=null; } }); window.addEventListener('focus',function(){ _check(); }); _startPoll(); } _startWebUIVersionSkewMonitor(); function _kanbanLooksLikeStaleClientError(err){ const msg = String((err && err.message) || err || '').toLowerCase(); return !!(err && err.status === 404 && ( msg === 'not found' || msg.includes('unknown kanban endpoint') || msg.includes('stale cached bundle') )); } function _kanbanUnavailableHtml(err){ const raw = String((err && err.message) || err || ''); if (_kanbanLooksLikeStaleClientError(err)) { return `
    Kanban needs a hard refresh
    The server rejected an obsolete Kanban endpoint. This usually means the browser or Mac app is still running a stale cached WebUI bundle after an update.
    Original error: ${esc(raw || 'not found')}
    `; } const msg = `${esc(t('kanban_unavailable'))}: ${esc(raw)}`; return `
    ${msg}
    `; } async function loadKanban(animate){ const board = $('kanbanBoard'); const list = $('kanbanList'); try { if (animate && board) board.innerHTML = `
    ${esc(t('loading'))}
    `; // Resolve the active board before board-scoped requests. If another CLI or // tab archived the previous board, /boards can fall back to default instead // of leaving config/board pinned to a ghost slug. await loadKanbanBoards(); const config = await api('/api/kanban/config' + _kanbanBoardQuery()); let assignees = null; try { assignees = await api('/api/kanban/assignees' + _kanbanBoardQuery()); } catch(e) { assignees = null; } _kanbanApplyConfigDefaults(config); const filters = _kanbanCurrentFilters(); const params = new URLSearchParams(); if (filters.assignee) params.set('assignee', filters.assignee); if (filters.tenant) params.set('tenant', filters.tenant); if (filters.includeArchived) params.set('include_archived', '1'); if (filters.onlyMine) params.set('only_mine', '1'); if (_kanbanCurrentBoard) params.set('board', _kanbanCurrentBoard); const path = '/api/kanban/board' + (params.toString() ? '?' + params.toString() : ''); const data = await api(path); if (data && data.changed === false && _kanbanBoard) { _kanbanRenderBoard(); return; } _kanbanBoard = data || {columns: []}; if ((!_kanbanBoard.columns || !_kanbanBoard.columns.length) && config && config.columns) { _kanbanBoard.columns = config.columns.map(name => ({name, tasks: []})); } _kanbanLatestEventId = Number(_kanbanBoard.latest_event_id || 0); // Toggle the "Read-only view" banner based on the bridge's read_only flag. // Bridge sets read_only=true only when the kanban_db connection cannot accept // writes (e.g. dispatcher contention or library missing). Hide otherwise. try { const ro = document.querySelector('.kanban-readonly'); if (ro) ro.style.display = _kanbanBoard.read_only ? '' : 'none'; } catch(_) {} _kanbanSetSelectOptions($('kanbanAssigneeFilter'), _kanbanBoard.assignees || (assignees && assignees.assignees) || (config && config.assignees), 'kanban_all_assignees'); _kanbanSetSelectOptions($('kanbanTenantFilter'), _kanbanBoard.tenants, 'kanban_all_tenants'); await loadKanbanStats(); // Note: PR #1828 (v0.51.20) moved the boards refresh to the start of // loadKanban() so the active board is resolved BEFORE board-scoped // requests fire. The previous tail-of-function refresh has been removed // to avoid doubling /api/kanban/boards traffic during SSE-driven // refreshes (debounced at 250ms via _scheduleKanbanRefresh). The // 30-second poll started by _kanbanStartPolling() picks up any board // state changes that arrive after this render. _kanbanStartPolling(); _kanbanRenderBoard(); } catch(e) { const html = _kanbanUnavailableHtml(e); if (board) board.innerHTML = html; if (list) list.innerHTML = html; } } function filterKanban(){ _kanbanRenderBoard(); } async function loadKanbanStats(){ try { const stats = await api('/api/kanban/stats' + _kanbanBoardQuery()); const el = $('kanbanStats'); if (!el) return; const byStatus = (stats && stats.by_status) || {}; const total = Object.values(byStatus).reduce((a, b) => a + Number(b || 0), 0); const cells = Object.entries(byStatus).sort(([a], [b]) => a.localeCompare(b)).map(([status, count]) => `${esc(String(count))} ${esc(_kanbanColumnLabel(status))}` ).join(''); el.innerHTML = `
    ${esc(String(total))} ${esc(t('kanban_stats'))}${cells}
    `; } catch(e) { /* stats are best-effort */ } } async function refreshKanbanEvents(){ if (_currentPanel !== 'kanban' || !_kanbanLatestEventId) return; try { const eventsEndpoint = '/api/kanban/events'; const events = await api(eventsEndpoint + _kanbanBoardQuery({since: _kanbanLatestEventId})); if (events && Array.isArray(events.events) && events.events.length) { _kanbanLatestEventId = Number(events.latest_event_id || events.cursor || _kanbanLatestEventId); await loadKanban(true); if (_kanbanCurrentTaskId && events.events.some(ev => ev.task_id === _kanbanCurrentTaskId)) await loadKanbanTask(_kanbanCurrentTaskId); } } catch(e) { /* polling should not spam toasts */ } } function _kanbanStartPolling(){ // Prefer SSE for low-latency live updates. Fall back to polling on // browsers without EventSource or after repeated stream failures. if (typeof EventSource === 'undefined' || _kanbanEventSourceFailures >= 3) { if (_kanbanPollTimer) return; _kanbanPollTimer = setInterval(refreshKanbanEvents, 30000); return; } _kanbanStartEventStream(); } function _kanbanStopPolling(){ if (_kanbanPollTimer) { clearInterval(_kanbanPollTimer); _kanbanPollTimer = null; } if (_kanbanEventSource) { try { if(_kanbanEventSource.readyState!==2)_kanbanEventSource.close(); } catch(_) {} _kanbanEventSource = null; } } function _kanbanStartEventStream(){ // Tear down any prior stream before opening a new one (board switch, // login change, etc.). if (_kanbanEventSource) { try { if(_kanbanEventSource.readyState!==2)_kanbanEventSource.close(); } catch(_) {} _kanbanEventSource = null; } const since = Number(_kanbanLatestEventId || 0); let url = '/api/kanban/events/stream' + _kanbanBoardQuery({since: since}); let es; try { es = new EventSource(url); } catch(e) { _kanbanEventSourceFailures += 1; if (_kanbanEventSourceFailures < 3 && !_kanbanPollTimer) { _kanbanPollTimer = setInterval(refreshKanbanEvents, 30000); } return; } _kanbanEventSource = es; es.addEventListener('hello', (ev) => { // Reset the failure counter on a successful handshake. _kanbanEventSourceFailures = 0; }); es.addEventListener('events', async (ev) => { if (_currentPanel !== 'kanban') return; // ignore while user is on another panel let data; try { data = JSON.parse(ev.data); } catch(_) { return; } if (!data || !Array.isArray(data.events) || !data.events.length) return; _kanbanLatestEventId = Number(data.cursor || _kanbanLatestEventId); // Re-fetch the board so the visual state reflects the new events. // Throttle: if events are arriving faster than ~1/sec we coalesce. _scheduleKanbanRefresh(data.events); }); es.onerror = () => { _kanbanEventSourceFailures += 1; if (_kanbanEventSourceFailures >= 3) { // Give up on SSE for this session — fall back to HTTP polling. try { es.close(); } catch(_) {} _kanbanEventSource = null; if (!_kanbanPollTimer) _kanbanPollTimer = setInterval(refreshKanbanEvents, 30000); } // EventSource auto-reconnects under the hood; nothing more to do here // until we hit the failure limit. }; } let _kanbanRefreshScheduled = false; let _kanbanRefreshPendingTaskIds = new Set(); function _scheduleKanbanRefresh(events){ for (const ev of events) { if (ev && ev.task_id) _kanbanRefreshPendingTaskIds.add(ev.task_id); } if (_kanbanRefreshScheduled) return; _kanbanRefreshScheduled = true; // 250ms debounce — keeps a burst of N events from triggering N reloads. setTimeout(async () => { _kanbanRefreshScheduled = false; const taskIds = Array.from(_kanbanRefreshPendingTaskIds); _kanbanRefreshPendingTaskIds.clear(); if (_currentPanel !== 'kanban') return; try { await loadKanban(true); if (_kanbanCurrentTaskId && taskIds.includes(_kanbanCurrentTaskId)) { await loadKanbanTask(_kanbanCurrentTaskId); } } catch(_) { /* swallow — SSE refresh shouldn't toast */ } }, 250); } // Build a "?board=" or "?since=N&board=" query string fragment // based on the active board. Empty when the user is on the default board // AND nobody has explicitly switched (so we don't pin to "default" and // override a hypothetical server-side switch). function _kanbanBoardQuery(extra){ const params = new URLSearchParams(); if (extra) { for (const [k, v] of Object.entries(extra)) { if (v !== null && v !== undefined && v !== '') params.set(k, String(v)); } } if (_kanbanCurrentBoard) params.set('board', _kanbanCurrentBoard); const s = params.toString(); return s ? '?' + s : ''; } async function nudgeKanbanDispatcher(){ if (_kanbanIsDispatching) return; // Dry-run dispatch: show what WOULD be spawned, without actually spawning // workers. Uses ?dry_run=1 so the dispatcher reports its plan without // mutating the board. The result shape includes spawned/skipped_unassigned/ // skipped_nonspawnable/promoted/auto_blocked so users can diagnose why a // Ready task isn't being picked up before they commit to a real run. _kanbanIsDispatching = true; _setKanbanDispatcherButtonsDisabled(true); try { const dispatchEndpoint = '/api/kanban/dispatch'; const result = await api( dispatchEndpoint + '?dry_run=1&max=8' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''), {method: 'POST'}, ); showToast(_kanbanFormatDispatchResult(result, true), 'info', 6000); await loadKanban(true); } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } finally { _kanbanIsDispatching = false; _setKanbanDispatcherButtonsDisabled(false); } } async function runKanbanDispatcher(){ if (_kanbanIsDispatching) return; // Real dispatch: claims Ready tasks and spawns worker subprocesses // (one `hermes -p ` per claimed row, up to max=8 per call). // Confirmation dialog first because this actually consumes API budget on // each spawned worker. Result toast surfaces what happened so users see // the dispatcher actually doing work. _kanbanIsDispatching = true; _setKanbanDispatcherButtonsDisabled(true); try { const ok = await showConfirmDialog({ title: t('kanban_run_dispatcher') || 'Run dispatcher', message: t('kanban_run_dispatcher_confirm') || 'This will claim Ready tasks on this board and spawn worker subprocesses (one per task, up to 8 per click). Continue?', confirmLabel: t('kanban_run_dispatcher') || 'Run dispatcher', }); if (!ok) return; const dispatchEndpoint = '/api/kanban/dispatch'; const result = await api( dispatchEndpoint + '?max=8' + (_kanbanCurrentBoard ? '&board=' + encodeURIComponent(_kanbanCurrentBoard) : ''), {method: 'POST'}, ); showToast(_kanbanFormatDispatchResult(result, false), 'info', 8000); await loadKanban(true); } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } finally { _kanbanIsDispatching = false; _setKanbanDispatcherButtonsDisabled(false); } } function _setKanbanDispatcherButtonsDisabled(disabled){ document.querySelectorAll('.kanban-run-dispatch-btn, .kanban-nudge-dispatch-btn').forEach((btn) => { btn.disabled = !!disabled; btn.classList.toggle('disabled', !!disabled); }); } function _kanbanFormatDispatchResult(result, dryRun){ // Produce a human-readable one-line summary of dispatch_once's output so // users can see exactly what happened rather than a generic "OK" toast. const r = result || {}; const spawned = (r.spawned || []).length; const promoted = r.promoted || 0; const reclaimed = r.reclaimed || 0; const skippedUnassigned = (r.skipped_unassigned || []).length; const skippedNonspawnable = (r.skipped_nonspawnable || []).length; const autoBlocked = (r.auto_blocked || []).length; const timedOut = (r.timed_out || []).length; const crashed = (r.crashed || []).length; const verb = dryRun ? (t('kanban_dispatch_preview_prefix') || 'Preview:') : (t('kanban_dispatch_run_prefix') || 'Dispatched:'); const parts = []; parts.push(spawned + ' ' + (t('kanban_dispatch_spawned') || 'spawned')); if (promoted) parts.push(promoted + ' ' + (t('kanban_dispatch_promoted') || 'promoted')); if (reclaimed) parts.push(reclaimed + ' ' + (t('kanban_dispatch_reclaimed') || 'reclaimed')); if (skippedUnassigned) parts.push(skippedUnassigned + ' ' + (t('kanban_dispatch_skipped_unassigned') || 'skipped (no assignee)')); if (skippedNonspawnable) parts.push(skippedNonspawnable + ' ' + (t('kanban_dispatch_skipped_nonspawnable') || 'skipped (unknown profile)')); if (autoBlocked) parts.push(autoBlocked + ' ' + (t('kanban_dispatch_auto_blocked') || 'auto-blocked')); if (timedOut) parts.push(timedOut + ' ' + (t('kanban_dispatch_timed_out') || 'timed out')); if (crashed) parts.push(crashed + ' ' + (t('kanban_dispatch_crashed') || 'crashed')); return verb + ' ' + parts.join(', '); } function _kanbanSelectedTaskIds(){ const selected = Array.from(document.querySelectorAll('.kanban-card.selected')).map(card => card.dataset.kanbanTaskId).filter(Boolean); return selected.length ? selected : (_kanbanCurrentTaskId ? [_kanbanCurrentTaskId] : []); } async function bulkUpdateKanban(){ const ids = _kanbanSelectedTaskIds(); const status = $('kanbanBulkStatus') ? $('kanbanBulkStatus').value : ''; if (!ids.length || !status) return; try { await api('/api/kanban/tasks/bulk' + _kanbanBoardQuery(), {method: 'POST', body: JSON.stringify({ids, status})}); showToast(t('kanban_bulk_action')); await loadKanban(true); } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } } async function blockKanbanTask(taskId){ try { await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + '/block' + _kanbanBoardQuery(), {method: 'POST', body: JSON.stringify({reason: 'blocked from WebUI'})}); await loadKanbanTask(taskId); await loadKanban(true); } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } } async function unblockKanbanTask(taskId){ try { await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + '/unblock' + _kanbanBoardQuery(), {method: 'POST', body: JSON.stringify({})}); await loadKanbanTask(taskId); await loadKanban(true); } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } } function closeKanbanTaskDetail(){ _kanbanCurrentTaskId = null; const preview = $('kanbanTaskPreview'); if (preview) { preview.style.display = 'none'; preview.innerHTML = ''; } const board = $('kanbanBoard'); if (board) board.querySelectorAll('.kanban-card').forEach(card => card.classList.remove('selected')); } function _kanbanFormatTimestamp(value){ if (value === undefined || value === null || value === '') return ''; let date = null; if (typeof value === 'number') date = new Date(value > 100000000000 ? value : value * 1000); else if (/^\d+(?:\.\d+)?$/.test(String(value).trim())) { const n = Number(value); date = new Date(n > 100000000000 ? n : n * 1000); } else { date = new Date(value); } if (!date || Number.isNaN(date.getTime())) return String(value); try { return date.toLocaleString(); } catch(e) { return date.toISOString(); } } function _kanbanEventSummary(event){ const kind = event.kind || event.type || 'event'; const payload = event.payload || event.data || {}; if (payload && typeof payload === 'object') { const parts = []; if (payload.status) parts.push(String(payload.status)); if (payload.reason) parts.push(String(payload.reason)); if (payload.summary) parts.push(String(payload.summary)); if (payload.fields && Array.isArray(payload.fields)) parts.push(payload.fields.join(', ')); if (parts.length) return `${kind}: ${parts.join(' · ')}`; } return String(kind); } function _kanbanFormatDetailValue(value){ if (value === undefined || value === null || value === '') return ''; if (typeof value === 'object') { try { return JSON.stringify(value, null, 2); } catch(e) { return String(value); } } return String(value); } function _kanbanDetailSection(cls, title, inner, emptyKey){ const content = inner || `
    ${esc(t(emptyKey))}
    `; return `

    ${esc(title)}

    ${content}
    `; } function _kanbanCommentHtml(comment){ const body = comment.body || comment.text || comment.content || ''; const by = comment.author || comment.created_by || comment.actor || ''; const at = _kanbanFormatTimestamp(comment.created_at || comment.ts || ''); return `
    ${_kanbanRenderMarkdown(body)}
    ${esc([by, at].filter(Boolean).join(' · '))}
    `; } function _kanbanEventHtml(event){ const at = _kanbanFormatTimestamp(event.created_at || event.ts || ''); const payload = _kanbanFormatDetailValue(event.payload || event.data || ''); return `
    ${esc(_kanbanEventSummary(event))}
    ${payload ? `
    ${esc(payload)}
    ` : ''}
    ${esc(at)}
    `; } function _kanbanRunHtml(run){ const status = run.status || run.state || run.result || ''; const label = run.run_id || run.id || run.worker || t('kanban_task'); const started = _kanbanFormatTimestamp(run.started_at || run.created_at || ''); const finished = _kanbanFormatTimestamp(run.finished_at || run.completed_at || ''); const detail = run.error || run.summary || run.log_tail || ''; return `
    ${esc(label)}${status ? ` · ${esc(status)}` : ''}
    ${detail ? `
    ${esc(_kanbanFormatDetailValue(detail))}
    ` : ''}
    ${esc([started, finished].filter(Boolean).join(' → '))}
    `; } function _kanbanLinkableTaskOptions(excludeId){ // Datalist of existing task ids (with title as the option label) so the // dependency field is a pick-from-real-tasks autocomplete rather than a blind // free-text opaque-id box. Mirrors the tenant datalist pattern. const cols = (_kanbanBoard && _kanbanBoard.columns) || []; const seen = new Set(); const opts = []; for (const col of cols) { for (const task of (col.tasks || [])) { const id = task && task.id; if (!id || id === excludeId || seen.has(id)) continue; seen.add(id); opts.push(``); } } return opts.join(''); } function _kanbanLinksHtml(links){ const parents = (links && links.parents) || []; const children = (links && links.children) || []; const taskId = _kanbanCurrentTaskId; const item = (id, isParent) => { const parentId = isParent ? id : taskId; const childId = isParent ? taskId : id; return `${esc(id)} `; }; const hasLinks = parents.length || children.length; return ``; } async function createKanbanTask(){ const input = document.getElementById('kanbanNewTaskTitle'); const title = input ? input.value.trim() : ''; if (!title) { // Empty inline input (or a click on the panel-head "+" via openKanbanCreate) // — open the full create-task modal so the user has somewhere obvious to // type and configure the task. Mirrors the cron / skills pattern of routing // header "+" clicks through to a clearly-modal create surface. openKanbanCreate(); return; } try { const created = await api('/api/kanban/tasks' + _kanbanBoardQuery(), { method: 'POST', body: JSON.stringify({title}), }); if (input) input.value = ''; await loadKanban(true); if (created && created.task && created.task.id) await loadKanbanTask(created.task.id); } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } } // ──────────────────────────────────────────────────────────────────────────── // Kanban: create-task modal (panel-head "+" button entry point). // // Same `.kanban-modal-overlay` shell as openKanbanCreateBoard() so the two // flows look and behave identically (centered card, dim backdrop, ESC closes, // click-on-backdrop closes). The modal markup lives in static/index.html as // #kanbanTaskModal — see the section just above . Submit hits the // existing /api/kanban/tasks POST endpoint (which already accepts title, body, // assignee, tenant, priority, status — see api/kanban_bridge.py:306). // ──────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────── // Kanban: create-task / edit-task modal (panel-head "+" + task-detail Edit // button entry points). // // Single modal serves both flows. Title + submit-button labels and the // underlying submit verb (POST vs PATCH) flip based on `_kanbanTaskModalMode`. // // Same `.kanban-modal-overlay` shell as openKanbanCreateBoard() so the two // flows look and behave identically (centered card, dim backdrop, ESC closes, // click-on-backdrop closes). The modal markup lives in static/index.html as // #kanbanTaskModal — see the section just above . // // The assignee field auto-completes against the union of (a) live Hermes // profile names from /api/profiles and (b) historical assignees on the // active board, with an inline hint that explains the dispatcher claim // contract — most users will pick a profile name from the dropdown rather // than type one. // ──────────────────────────────────────────────────────────────────────────── let _kanbanTaskModalMode = 'create'; // 'create' | 'edit' let _kanbanTaskModalEditingId = null; // task id when mode === 'edit' let _kanbanProfileNamesCache = null; // populated lazily on first modal open let _kanbanProfileNamesCacheAt = 0; const _KANBAN_PROFILE_NAMES_CACHE_TTL_MS = 30000; function _invalidateKanbanProfileCache() { _kanbanProfileNamesCache = null; _kanbanProfileNamesCacheAt = 0; } let _kanbanTaskModalFocusCleanup = null; // Status the modal *displayed* on edit-mode open. If the user doesn't touch // the dropdown, we must NOT send `status` in the PATCH payload — otherwise // editing a task whose real status is non-editable in this dropdown // (running/blocked/done/archived → mapped to 'triage' for display) would // silently demote the task on save. See the regression caught during PR // review: editing a 'running' task without touching status was reclaiming // the worker and moving the task back to triage. let _kanbanTaskModalInitialDisplayedStatus = null; let _kanbanBoardModalFocusCleanup = null; async function _kanbanLoadProfileNames(){ // Hit /api/profiles once per session and cache for a short TTL. // Returns an array of profile names (sorted, default first if present). const hasFreshCache = ( Array.isArray(_kanbanProfileNamesCache) && (Date.now() - _kanbanProfileNamesCacheAt) < _KANBAN_PROFILE_NAMES_CACHE_TTL_MS ); if (hasFreshCache) return _kanbanProfileNamesCache; try { const data = await api('/api/profiles'); const profiles = Array.isArray(data && data.profiles) ? data.profiles : []; const names = profiles.map(p => p && p.name).filter(Boolean); // Stable order: default first, then alphabetical. names.sort((a, b) => { if (a === 'default') return -1; if (b === 'default') return 1; return a.localeCompare(b); }); _kanbanProfileNamesCache = names; _kanbanProfileNamesCacheAt = Date.now(); return names; } catch(_) { _kanbanProfileNamesCache = []; _kanbanProfileNamesCacheAt = Date.now(); return []; } } async function _kanbanPopulateAssigneeSelect(currentValue){ const sel = document.getElementById('kanbanTaskModalAssignee'); if (!sel) return; // Profile names: the canonical set the dispatcher can claim. const profileNames = await _kanbanLoadProfileNames(); // Historical assignees from the active board: include them so users who // assigned to a CLI lane (e.g. orion-cc) before still see those values. const historicalAssignees = (_kanbanBoard && Array.isArray(_kanbanBoard.assignees)) ? _kanbanBoard.assignees : []; // Build a final ordered list, deduping. Profiles come first, then any // historical assignees that aren't profiles (rare but keeps round-tripping // correct for tasks created via CLI). const seen = new Set(); const profiles = []; for (const name of profileNames) { if (!seen.has(name)) { profiles.push(name); seen.add(name); } } const extras = []; for (const name of historicalAssignees) { if (name && !seen.has(name)) { extras.push(name); seen.add(name); } } // If the current value isn't in either bucket (e.g. an old CLI-created // assignee that's since been deleted), preserve it as a final option so // editing the task doesn't silently change its assignee. if (currentValue && !seen.has(currentValue)) { extras.push(currentValue); seen.add(currentValue); } // The empty value maps to null on submit (intentionally unassigned). Keep // it last so the default-selected option is the first profile, not "no one". let html = ''; if (profiles.length) { html += ``; html += profiles.map(v => ``).join(''); html += ''; } if (extras.length) { html += ``; html += extras.map(v => ``).join(''); html += ''; } // Final "no assignee" fallthrough — explicit so users know what they're choosing. html += ``; sel.innerHTML = html; } function openKanbanCreate(){ // Make sure the user is on the kanban panel so the resulting board reload is // visible behind the modal. if (typeof switchPanel === 'function' && _currentPanel !== 'kanban') switchPanel('kanban'); const modal = document.getElementById('kanbanTaskModal'); if (!modal) return; _kanbanTaskModalMode = 'create'; _kanbanTaskModalEditingId = null; _kanbanTaskModalInitialDisplayedStatus = null; // create mode: always send status // Default new tasks to "ready" so they're immediately claimable by the // dispatcher (assuming the user picks an assignee). Triage is for staging // tasks that need human review before being marked actionable; users who // want it can still pick it from the status dropdown. _kanbanResetTaskModalFields({status: 'ready'}); _kanbanSetTaskModalStatusHint(null); _kanbanSetTaskModalLabels('create'); _kanbanPopulateAssigneeSelect('').then(() => { // After the dropdown is populated, default-select the first profile (not // the "Unassigned" fallthrough). This is the right hint: most users want // to assign to *something* — they can pick "Unassigned" deliberately. const sel = document.getElementById('kanbanTaskModalAssignee'); if (sel && sel.options.length > 0 && sel.value === '') { const firstProfile = Array.from(sel.options).find(opt => opt.value !== ''); if (firstProfile) sel.value = firstProfile.value; } }); _kanbanPopulateTenantDatalist(); _kanbanPopulateWorkspacePathDatalist(); _kanbanPopulateParentsDatalist(); modal.hidden = false; if (_kanbanTaskModalFocusCleanup) { _kanbanTaskModalFocusCleanup(); _kanbanTaskModalFocusCleanup = null; } _kanbanTaskModalFocusCleanup = _trapModalFocus(modal); setTimeout(() => { const titleEl = document.getElementById('kanbanTaskModalTitleInput'); if (titleEl) titleEl.focus(); }, 50); document.addEventListener('keydown', _kanbanTaskModalKey); } async function openKanbanEdit(taskId){ // Triggered by the Edit button on the task detail view. Fetches the task // (rather than relying on whatever's cached locally) so the modal always // reflects authoritative server state. if (!taskId) return; if (typeof switchPanel === 'function' && _currentPanel !== 'kanban') switchPanel('kanban'); const modal = document.getElementById('kanbanTaskModal'); if (!modal) return; let task = null; try { const data = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + _kanbanBoardQuery()); task = data && data.task; } catch(e) { showToast((t('kanban_unavailable') || 'Kanban unavailable') + ': ' + (e.message || e), 'error'); return; } if (!task) return; _kanbanTaskModalMode = 'edit'; _kanbanTaskModalEditingId = task.id; // Track the displayed status so submitKanbanTaskModal can detect whether // the user actually picked a new value vs. the dropdown's mapped default. // Without this, editing a 'running'/'blocked'/'done'/'archived' task whose // real status maps to 'triage' for display would silently demote the task // (the mapped 'triage' would land in the PATCH payload, and _patch_task // would call _set_status_direct → reclaim worker → move to triage). const initialDisplayedStatus = _kanbanEditableStatusFor(task.status); const originalStatus = task.status || initialDisplayedStatus; _kanbanTaskModalInitialDisplayedStatus = initialDisplayedStatus; _kanbanResetTaskModalFields({ title: task.title || '', body: task.body || '', status: initialDisplayedStatus, tenant: task.tenant || '', priority: typeof task.priority === 'number' ? task.priority : 0, }); // Populate the assignee select AFTER reset so the option exists when we // call sel.value = currentAssignee. await _kanbanPopulateAssigneeSelect(task.assignee || ''); _kanbanSetTaskModalStatusHint(originalStatus, initialDisplayedStatus); _kanbanSetTaskModalLabels('edit'); _kanbanPopulateTenantDatalist(); modal.hidden = false; if (_kanbanTaskModalFocusCleanup) { _kanbanTaskModalFocusCleanup(); _kanbanTaskModalFocusCleanup = null; } _kanbanTaskModalFocusCleanup = _trapModalFocus(modal); setTimeout(() => { const titleEl = document.getElementById('kanbanTaskModalTitleInput'); if (titleEl) { titleEl.focus(); titleEl.select(); } }, 50); document.addEventListener('keydown', _kanbanTaskModalKey); } function _kanbanEditableStatusFor(status){ // The modal's status select only offers triage/todo/ready (the user-writable // states). blocked/running/done/archived are reached via the detail-view // status buttons or the dispatcher. Map non-editable states to a sensible // default so the user can still change them via the buttons after saving. const editable = new Set(['triage', 'todo', 'ready']); return editable.has(status) ? status : 'triage'; } function _kanbanResetTaskModalFields(values){ const v = values || {}; const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = (val == null ? '' : String(val)); }; set('kanbanTaskModalTitleInput', v.title || ''); set('kanbanTaskModalBody', v.body || ''); set('kanbanTaskModalStatus', v.status || 'triage'); // Assignee handled separately by _kanbanPopulateAssigneeSelect() because // it's a `; } async function loadKanbanTask(taskId){ if (!taskId) return; try { const data = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + _kanbanBoardQuery()); try { data.log = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + '/log' + _kanbanBoardQuery({tail: 65536})); } catch(e) { data.log = {}; } _kanbanCurrentTaskId = taskId; const task = data.task || {}; const title = _kanbanTaskTitle(task); const board = $('kanbanBoard'); if (board) { board.querySelectorAll('.kanban-card').forEach(card => card.classList.remove('selected')); Array.from(board.querySelectorAll('.kanban-card')).find(card => card.dataset.kanbanTaskId === taskId)?.classList.add('selected'); } const preview = $('kanbanTaskPreview'); if (preview) { preview.style.display = ''; preview.innerHTML = _kanbanRenderTaskDetail(data); } _closeMobileSidebarAfterPanelSelection(); showToast(`${t('kanban_task')}: ${title}`); } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } } // Phase 2: Single-source-of-truth render. // // Reads `S.todos` (set by the `todo_state` SSE listener, INFLIGHT // restore, or session cold-load — see _hydrateTodosFromSession in // ui.js). When `S.todoStateMeta` is null we have never seen an // explicit signal and fall through to the legacy reverse-scan over // settled tool messages — this keeps the panel populated against // pre-Phase-1 servers and during the upgrade window. // // The render is short-circuited via `_todosLastRenderedHash` (defined // in ui.js): repeated emissions that yield identical DOM are no-ops. // Coalescing of bursty live updates happens upstream in // scheduleTodosRefresh(). function loadTodos() { const panel = $('todoPanel'); if (!panel) return; let todos; if (S.todoStateMeta) { todos = Array.isArray(S.todos) ? S.todos : []; } else { todos = _legacyTodosFromMessages(); } if (!todos.length) { if (typeof _todosLastRenderedHash !== 'undefined' && _todosLastRenderedHash === '__empty__') return; panel.innerHTML = renderTodoEmptyState(); if (typeof _todosLastRenderedHash !== 'undefined') _todosLastRenderedHash = '__empty__'; return; } if (typeof _todosHash === 'function' && typeof _todosLastRenderedHash !== 'undefined') { const hash = _todosHash(todos); if (hash === _todosLastRenderedHash) return; _todosLastRenderedHash = hash; } // Single innerHTML join is the cheapest correct way to materialize // ~10–50 leaf nodes. All user-controlled content goes through esc(). panel.innerHTML = renderTodoRows(todos, {metadata:true}); } // Legacy fallback: reverse-scan settled tool messages for the most // recent {"todos":[...]} payload. Used only when no `todo_state` // signal has been seen for the current session — primarily during // upgrade windows where the server has not yet been redeployed with // Phase 1. Once Phase 1 is universally deployed and a stabilization // period has passed, this can be removed (Phase 3). // // Variable name `sourceMessages` is preserved verbatim from the // original loadTodos() implementation so the matching regression // test (R-todo-survive-refresh in tests/test_regressions.py) keeps // catching any future refactor that drops the raw-session-messages // fallback. See the test for the exact contract. function _legacyTodosFromMessages() { const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages; if (!Array.isArray(sourceMessages)) return []; for (let i = sourceMessages.length - 1; i >= 0; i--) { const m = sourceMessages[i]; if (!m || m.role !== 'tool') continue; let content = m.content; if (typeof content !== 'string') { try { content = JSON.stringify(content); } catch (_) { continue; } } if (!content || content.indexOf('"todos"') < 0) continue; try { const d = JSON.parse(content); if (d && Array.isArray(d.todos)) return d.todos; } catch (_) {} } return []; } // ──────────────────────────────────────────────────────────────────────────── // Kanban: multi-board switcher + create/rename/archive modal // ──────────────────────────────────────────────────────────────────────────── // // The bridge exposes /api/kanban/boards (GET/POST), /boards/ // (PATCH/DELETE), and /boards//switch (POST). The UI surfaces these // as a "Default ▾" dropdown next to the Board title — clicking it opens // a menu listing every board (current first, with task counts), plus // actions to create / rename / archive. const KANBAN_BOARD_LS_KEY = 'hermes-kanban-active-board'; function _kanbanGetSavedBoard(){ try { return localStorage.getItem(KANBAN_BOARD_LS_KEY) || null; } catch(_) { return null; } } function _kanbanSetSavedBoard(slug){ try { if (slug && slug !== 'default') localStorage.setItem(KANBAN_BOARD_LS_KEY, slug); else localStorage.removeItem(KANBAN_BOARD_LS_KEY); } catch(_) {} } async function loadKanbanBoards(){ // Fetches the boards list and updates the switcher UI. Best-effort — // failures hide the switcher rather than blocking the panel from rendering. const switcher = document.getElementById('kanbanBoardSwitcher'); if (!switcher) return; let data; try { data = await api('/api/kanban/boards'); } catch(e) { // Hide switcher on error so the user isn't stuck with a half-broken UI. switcher.hidden = true; return; } const boards = (data && data.boards) || []; const serverCurrent = (data && data.current) || 'default'; _kanbanBoardsList = boards; // Resolution chain for the active board: // localStorage hint → server's `current` → 'default'. // The localStorage hint is honoured ONLY if it points at a board that // still exists; otherwise we fall back to the server's pointer. const saved = _kanbanGetSavedBoard(); let active = serverCurrent; if (saved && boards.some(b => b.slug === saved)) { active = saved; } else if (saved) { _kanbanSetSavedBoard('default'); } _kanbanCurrentBoard = (active === 'default') ? null : active; // Keep the switcher visible because it is also the default-board settings path. switcher.hidden = false; // Update the toggle label/icon const activeMeta = boards.find(b => b.slug === active) || {slug: active, name: active, icon: '', color: ''}; const nameEl = document.getElementById('kanbanBoardSwitcherName'); const iconEl = document.getElementById('kanbanBoardSwitcherIcon'); if (nameEl) nameEl.textContent = activeMeta.name || activeMeta.slug || 'Default'; if (iconEl) { iconEl.textContent = activeMeta.icon || ''; if (activeMeta.color) iconEl.style.color = activeMeta.color; else iconEl.style.color = ''; } // Re-render the menu (in case it was open or changed) _renderKanbanBoardMenu(boards, active); } // Restrict board.color to CSS hex codes or simple named colors before // interpolating into a `style=""` attribute. esc() HTML-escapes but // does not block CSS-context injection (`color:red;background:url(...)` // would otherwise exfiltrate page state via an attacker-controlled URL, // since neither this bridge nor the agent's kanban_db validates color). function _kanbanSafeColor(c){ if (typeof c !== 'string') return ''; const s = c.trim(); if (!s) return ''; if (/^#[0-9a-fA-F]{3,8}$/.test(s)) return s; if (/^[a-zA-Z]{3,32}$/.test(s)) return s; return ''; } function _renderKanbanBoardMenu(boards, current){ const menu = document.getElementById('kanbanBoardSwitcherMenu'); if (!menu) return; const items = boards.map(b => { const isCurrent = b.slug === current; const total = (b.total != null) ? b.total : (b.counts ? Object.values(b.counts).reduce((a,c)=>a+Number(c||0),0) : 0); const icon = b.icon ? esc(b.icon) : ''; const safeColor = _kanbanSafeColor(b.color); const colorStyle = safeColor ? `color:${safeColor}` : ''; return ``; }).join(''); // The default board is editable but cannot be archived. const archiveDisabled = current === 'default'; const actions = ` `; menu.innerHTML = items + actions; } function toggleKanbanBoardMenu(ev){ if (ev) ev.stopPropagation(); const menu = document.getElementById('kanbanBoardSwitcherMenu'); const toggle = document.getElementById('kanbanBoardSwitcherToggle'); if (!menu || !toggle) return; _kanbanBoardMenuOpen = !_kanbanBoardMenuOpen; menu.hidden = !_kanbanBoardMenuOpen; toggle.setAttribute('aria-expanded', String(_kanbanBoardMenuOpen)); if (_kanbanBoardMenuOpen) { // Click-away close setTimeout(() => { document.addEventListener('click', _kanbanCloseBoardMenuOnOutside, {once: true, capture: true}); }, 0); } } function _kanbanCloseBoardMenuOnOutside(ev){ const switcher = document.getElementById('kanbanBoardSwitcher'); if (!switcher || !switcher.contains(ev.target)) { _kanbanBoardMenuOpen = false; const menu = document.getElementById('kanbanBoardSwitcherMenu'); const toggle = document.getElementById('kanbanBoardSwitcherToggle'); if (menu) menu.hidden = true; if (toggle) toggle.setAttribute('aria-expanded', 'false'); } else { // Re-arm the listener — the user clicked inside the switcher, possibly // the toggle button which we want to handle through its own onclick. setTimeout(() => { document.addEventListener('click', _kanbanCloseBoardMenuOnOutside, {once: true, capture: true}); }, 0); } } async function switchKanbanBoard(slug){ if (!slug) return; const newBoard = (slug === 'default') ? null : slug; if (newBoard === _kanbanCurrentBoard) { // No-op switch — just close the menu. _kanbanBoardMenuOpen = false; const menu = document.getElementById('kanbanBoardSwitcherMenu'); if (menu) menu.hidden = true; return; } _kanbanCurrentBoard = newBoard; _kanbanSetSavedBoard(slug); _kanbanLatestEventId = 0; // reset cursor — new board has its own event sequence _kanbanBoardMenuOpen = false; const menu = document.getElementById('kanbanBoardSwitcherMenu'); if (menu) menu.hidden = true; // Tell the server too (sets the on-disk active-board pointer for CLI/dashboard). try { await api('/api/kanban/boards/' + encodeURIComponent(slug) + '/switch', {method: 'POST'}); } catch(e) { // Local UI switch still happens — the on-disk pointer is for cross-process // consistency, not for our own rendering. } // Re-open the SSE stream on the new board. _kanbanStopPolling(); await loadKanban(true); await loadKanbanBoards(); _kanbanStartPolling(); } // ── Create / rename / archive board modals ────────────────────────────────── async function _loadKanbanBoardWorkdirOptions(){ await loadWorkspaceList(); const list = document.getElementById('kanbanBoardModalWorkdirs'); if (!list) return; list.innerHTML = (_workspaceList || []).map(ws => { const path = typeof ws === 'string' ? ws : ws && ws.path; return path ? `` : ''; }).join(''); } function openKanbanCreateBoard(){ const modal = document.getElementById('kanbanBoardModal'); if (!modal) return; document.getElementById('kanbanBoardModalMode').value = 'create'; document.getElementById('kanbanBoardModalSlug').value = ''; document.getElementById('kanbanBoardModalTitle').textContent = t('kanban_new_board') || 'New board'; document.getElementById('kanbanBoardModalName').value = ''; document.getElementById('kanbanBoardModalSlugInput').value = ''; document.getElementById('kanbanBoardModalSlugInput').disabled = false; document.getElementById('kanbanBoardModalSlugRow').style.display = ''; document.getElementById('kanbanBoardModalDesc').value = ''; document.getElementById('kanbanBoardModalIcon').value = ''; document.getElementById('kanbanBoardModalColor').value = '#7aa2ff'; document.getElementById('kanbanBoardModalDefaultWorkdir').value = ''; document.getElementById('kanbanBoardModalOriginalDefaultWorkdir').value = ''; _loadKanbanBoardWorkdirOptions(); document.getElementById('kanbanBoardModalError').textContent = ''; modal.hidden = false; if (_kanbanBoardModalFocusCleanup) { _kanbanBoardModalFocusCleanup(); _kanbanBoardModalFocusCleanup = null; } _kanbanBoardModalFocusCleanup = _trapModalFocus(modal); // Auto-focus name field setTimeout(() => document.getElementById('kanbanBoardModalName').focus(), 50); // Auto-suggest slug from name as user types const nameEl = document.getElementById('kanbanBoardModalName'); const slugEl = document.getElementById('kanbanBoardModalSlugInput'); let userEditedSlug = false; slugEl.addEventListener('input', () => { userEditedSlug = true; }, {once: false}); const onName = () => { if (!userEditedSlug) { slugEl.value = String(nameEl.value || '').toLowerCase().replace(/[^a-z0-9-_ ]+/g, '').replace(/\s+/g, '-').slice(0, 48); } }; nameEl.removeEventListener('input', nameEl._kanbanOnNameInput || (() => {})); nameEl._kanbanOnNameInput = onName; nameEl.addEventListener('input', onName); // Close on Escape document.addEventListener('keydown', _kanbanBoardModalEsc); } function openKanbanRenameBoard(){ const modal = document.getElementById('kanbanBoardModal'); if (!modal) return; const current = _kanbanCurrentBoard || 'default'; const meta = (_kanbanBoardsList || []).find(b => b.slug === current); if (!meta) return; document.getElementById('kanbanBoardModalMode').value = 'rename'; document.getElementById('kanbanBoardModalSlug').value = current; document.getElementById('kanbanBoardModalTitle').textContent = t('kanban_board_settings') || 'Board settings'; document.getElementById('kanbanBoardModalName').value = meta.name || ''; document.getElementById('kanbanBoardModalSlugInput').value = current; document.getElementById('kanbanBoardModalSlugInput').disabled = true; // slug is immutable // Hide the slug row — it's locked, less visual noise. document.getElementById('kanbanBoardModalSlugRow').style.display = 'none'; document.getElementById('kanbanBoardModalDesc').value = meta.description || ''; document.getElementById('kanbanBoardModalIcon').value = meta.icon || ''; document.getElementById('kanbanBoardModalColor').value = meta.color || '#7aa2ff'; const originalDefaultWorkdir = (meta.default_workdir || '').trim(); document.getElementById('kanbanBoardModalDefaultWorkdir').value = originalDefaultWorkdir; document.getElementById('kanbanBoardModalOriginalDefaultWorkdir').value = originalDefaultWorkdir; _loadKanbanBoardWorkdirOptions(); document.getElementById('kanbanBoardModalError').textContent = ''; modal.hidden = false; if (_kanbanBoardModalFocusCleanup) { _kanbanBoardModalFocusCleanup(); _kanbanBoardModalFocusCleanup = null; } _kanbanBoardModalFocusCleanup = _trapModalFocus(modal); setTimeout(() => document.getElementById('kanbanBoardModalName').focus(), 50); document.addEventListener('keydown', _kanbanBoardModalEsc); } function _kanbanBoardModalEsc(ev){ if (ev.key === 'Escape') closeKanbanBoardModal(); } function closeKanbanBoardModal(){ const modal = document.getElementById('kanbanBoardModal'); if (modal) modal.hidden = true; if (_kanbanBoardModalFocusCleanup) { _kanbanBoardModalFocusCleanup(); _kanbanBoardModalFocusCleanup = null; } document.removeEventListener('keydown', _kanbanBoardModalEsc); } async function submitKanbanBoardModal(){ const errEl = document.getElementById('kanbanBoardModalError'); errEl.textContent = ''; const mode = document.getElementById('kanbanBoardModalMode').value; const name = (document.getElementById('kanbanBoardModalName').value || '').trim(); const slugInput = (document.getElementById('kanbanBoardModalSlugInput').value || '').trim(); const description = (document.getElementById('kanbanBoardModalDesc').value || '').trim(); const icon = (document.getElementById('kanbanBoardModalIcon').value || '').trim(); const color = (document.getElementById('kanbanBoardModalColor').value || '').trim(); const defaultWorkdir = (document.getElementById('kanbanBoardModalDefaultWorkdir').value || '').trim(); const originalDefaultWorkdir = (document.getElementById('kanbanBoardModalOriginalDefaultWorkdir').value || '').trim(); const submitBtn = document.getElementById('kanbanBoardModalSubmit'); if (!name) { errEl.textContent = t('kanban_board_name_required') || 'Name is required'; return; } if (mode === 'create') { if (!slugInput) { errEl.textContent = t('kanban_board_slug_required') || 'Slug is required'; return; } if (submitBtn) submitBtn.disabled = true; try { const payload = {slug: slugInput, name, description, icon, color, switch: true}; if (defaultWorkdir) payload.default_workdir = defaultWorkdir; const res = await api('/api/kanban/boards', { method: 'POST', body: JSON.stringify(payload), }); closeKanbanBoardModal(); // Switch to the new board and reload const newSlug = (res && res.board && res.board.slug) || slugInput; _kanbanCurrentBoard = (newSlug === 'default') ? null : newSlug; _kanbanSetSavedBoard(newSlug); _kanbanLatestEventId = 0; _kanbanStopPolling(); await loadKanban(true); await loadKanbanBoards(); _kanbanStartPolling(); } catch(e) { errEl.textContent = (e && (e.message || e.error)) || String(e); } finally { if (submitBtn) submitBtn.disabled = false; } } else if (mode === 'rename') { const slug = document.getElementById('kanbanBoardModalSlug').value; if (!slug) { errEl.textContent = 'Missing slug'; return; } if (submitBtn) submitBtn.disabled = true; try { const payload = {name, description, icon, color}; if (defaultWorkdir !== originalDefaultWorkdir) payload.default_workdir = defaultWorkdir; await api('/api/kanban/boards/' + encodeURIComponent(slug), { method: 'PATCH', body: JSON.stringify(payload), }); closeKanbanBoardModal(); await loadKanbanBoards(); // refresh switcher label/icon } catch(e) { errEl.textContent = (e && (e.message || e.error)) || String(e); } finally { if (submitBtn) submitBtn.disabled = false; } } } async function archiveKanbanBoard(){ const current = _kanbanCurrentBoard || 'default'; if (current === 'default') return; const meta = (_kanbanBoardsList || []).find(b => b.slug === current); const label = meta && meta.name ? meta.name : current; const ok = await showConfirmDialog({ title: t('kanban_archive_board') || 'Archive board', message: (t('kanban_archive_board_confirm') || 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.').replace('{name}', label), confirmLabel: t('kanban_archive_board') || 'Archive', danger: true, focusCancel: true, }); if (!ok) return; // CRITICAL: stop the SSE stream BEFORE the archive call. The library's // kb.connect(board=) auto-creates the on-disk directory + DB on // first call — so any in-flight stream that polls task_events while // we're archiving will silently re-materialise the directory we just // moved to _archived/. Tearing down the stream first avoids that race. _kanbanStopPolling(); try { await api('/api/kanban/boards/' + encodeURIComponent(current), {method: 'DELETE'}); // Server falls back to default — match that locally. _kanbanCurrentBoard = null; _kanbanSetSavedBoard('default'); _kanbanLatestEventId = 0; await loadKanban(true); await loadKanbanBoards(); _kanbanStartPolling(); showToast(t('kanban_board_archived') || 'Board archived'); } catch(e) { // Restart the stream on failure so the UI doesn't go stale. _kanbanStartPolling(); showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } } // ── Logs panel ── function _selectedLogsFile() { const el = $('logsFile'); const value = (el && el.value) || 'agent'; return ['agent','errors','gateway'].includes(value) ? value : 'agent'; } function _selectedLogsTail() { const el = $('logsTail'); const value = Number((el && el.value) || 200); return [100,200,500,1000].includes(value) ? value : 200; } function _severityForLine(line) { const text = String(line || '').toUpperCase(); if (/\b(ERROR|CRITICAL|TRACEBACK)\b/.test(text)) return 'error'; if (/\b(WARNING|WARN)\b/.test(text)) return 'warning'; if (/\b(DEBUG)\b/.test(text)) return 'debug'; if (/\b(INFO)\b/.test(text)) return 'info'; return 'other'; } function _filteredLogsLines() { if (_logsSeverityFilter === 'all') return _lastLogsLines; return _lastLogsLines.filter(line => { const sev = _severityForLine(line); if (_logsSeverityFilter === 'errors') return sev === 'error'; if (_logsSeverityFilter === 'warnings') return sev === 'warning' || sev === 'error'; return true; }); } function _applyLogsSeverityFilter() { const el = $('logsSeverityFilter'); _logsSeverityFilter = (el && el.value) || 'all'; // Re-render from cached lines without re-fetching _renderLogs({ lines: _lastLogsLines, hint: '', truncated: false, _fromFilter: true }); } function _logLineSeverityClass(line) { const text = String(line || '').toUpperCase(); if (/\b(WARNING|WARN)\b/.test(text)) return 'log-line-warning'; if (/\b(DEBUG)\b/.test(text)) return 'log-line-debug'; if (/\b(INFO)\b/.test(text)) return 'log-line-info'; if (/\b(ERROR|CRITICAL|TRACEBACK)\b/.test(text)) return 'log-line-error'; return ''; } function _syncLogsWrap() { const out = $('logsOutput'); const wrap = $('logsWrap'); if (out && wrap) out.classList.toggle('wrap', !!wrap.checked); } async function loadLogs(animate) { const box = $('logsOutput'); const status = $('logsStatus'); const refreshBtn = $('logsRefreshBtn'); if (!box) return; if (animate && refreshBtn) { refreshBtn.style.opacity = '0.5'; refreshBtn.disabled = true; } const file = _selectedLogsFile(); const tail = _selectedLogsTail(); try { if (status) status.textContent = t('logs_loading'); const data = await api('/api/logs?file=' + encodeURIComponent(file) + '&tail=' + encodeURIComponent(tail)); _renderLogs(data); } catch(e) { _lastLogsLines = []; box.innerHTML = `
    ${esc(t('error_prefix') + e.message)}
    `; if (status) status.textContent = t('logs_load_failed'); } finally { if (animate && refreshBtn) { refreshBtn.style.opacity = ''; refreshBtn.disabled = false; } _syncLogsAutoRefresh(); } } function _renderLogs(data) { const box = $('logsOutput'); const status = $('logsStatus'); if (!box) return; const rawLines = Array.isArray(data && data.lines) ? data.lines : []; // Only update cache when loading fresh data (not when re-rendering from filter) if (data && !data._fromFilter) _lastLogsLines = rawLines.slice(); const displayLines = _filteredLogsLines(); const hint = data && data.hint ? `
    ${esc(data.hint)}
    ` : ''; const truncated = data && data.truncated ? `
    ${esc(t('logs_truncated_hint'))}
    ` : ''; const filterNote = _logsSeverityFilter !== 'all' ? `
    ${esc(displayLines.length + ' / ' + _lastLogsLines.length + ' ' + t('logs_filter_active'))}
    ` : ''; if (!displayLines.length) { box.innerHTML = `${hint}${truncated}${filterNote}
    ${esc(t('logs_empty'))}
    `; } else { box.innerHTML = `${hint}${truncated}${filterNote}` + displayLines.map(line => { const cls = _logLineSeverityClass(line); return `
    ${esc(line)}
    `; }).join(''); } _syncLogsWrap(); if (status) { const bytes = data && Number(data.total_bytes || 0); const when = data && data.mtime ? new Date(data.mtime * 1000).toLocaleString() : t('logs_no_mtime'); status.textContent = `${rawLines.length} / ${data.tail || _selectedLogsTail()} lines · ${bytes.toLocaleString()} bytes · ${when}`; } } function _startLogsAutoRefresh() { if (_logsAutoRefreshTimer) return; _logsAutoRefreshTimer = setInterval(() => { if (_currentPanel !== 'logs') { _stopLogsAutoRefresh(); return; } const toggle = $('logsAutoRefresh'); if (toggle && !toggle.checked) return; loadLogs(false); }, 5000); } function _stopLogsAutoRefresh() { if (_logsAutoRefreshTimer) { clearInterval(_logsAutoRefreshTimer); _logsAutoRefreshTimer = null; } } function _syncLogsAutoRefresh() { const toggle = $('logsAutoRefresh'); if (_currentPanel === 'logs' && (!toggle || toggle.checked)) _startLogsAutoRefresh(); else _stopLogsAutoRefresh(); } async function copyLogsAll() { const lines = _filteredLogsLines(); const text = lines.join('\n'); try { await _copyText(text); showToast(t('logs_copied')); } catch(e) { showToast(t('copy_failed'), 'error'); } } // ── Insights panel ── const STATIC_MODEL_HEALTH_ROWS = [ {id:'openai/gpt-5.4-mini', provider:'OpenAI', inputCostPerM:0.25, outputCostPerM:2.00, replacement:'Default economical general-purpose model'}, {id:'openai/gpt-5.4', provider:'OpenAI', inputCostPerM:2.00, outputCostPerM:10.00, replacement:'Use for complex synthesis; fall back to Mini for routine turns'}, {id:'anthropic/claude-sonnet-4.5', provider:'Anthropic', inputCostPerM:3.00, outputCostPerM:15.00, replacement:'Strong coding and analysis option; use Mini for low-risk chat'}, {id:'google/gemini-2.5-pro', provider:'Google', inputCostPerM:1.25, outputCostPerM:10.00, replacement:'Long-context research option; use Flash for speed-sensitive work'}, {id:'google/gemini-2.5-flash', provider:'Google', inputCostPerM:0.30, outputCostPerM:2.50, replacement:'Low-latency replacement for lighter multimodal or research turns'}, ]; function _renderModelHealthCost(row) { const input = Number(row.inputCostPerM || 0); const output = Number(row.outputCostPerM || 0); return `$${input.toFixed(2)} / $${output.toFixed(2)}`; } function _renderStaticModelHealthTable() { const rows = STATIC_MODEL_HEALTH_ROWS.map(row => `
    ${esc(row.id)} ${esc(row.provider)} ${esc(_renderModelHealthCost(row))} ${esc(row.replacement)}
    `).join(''); return `
    ${esc(t('insights_model_health_title'))}
    ${esc(t('insights_model_name'))}${esc(t('insights_model_health_provider'))}${esc(t('insights_model_health_cost_per_m'))}${esc(t('insights_model_health_replacement'))}
    ${rows}
    `; } async function loadInsights(animate) { const box = $('insightsContent'); const refreshBtn = $('insightsRefreshBtn'); if (!box) return; if (animate && refreshBtn) { refreshBtn.style.opacity = '0.5'; refreshBtn.disabled = true; } const period = ($('insightsPeriod') || {}).value || '30'; try { const [data, wikiStatus, skillUsage] = await Promise.all([ api(`/api/insights?days=${period}`), api('/api/wiki/status').catch(err => ({status:'error', error: err.message || String(err)})), api('/api/skills/usage').catch(() => ({usage:{}, skill_names:[], total_invocations:0, unique_skills_used:0})), ]); _renderInsights(data, box, wikiStatus, skillUsage); if (typeof _syncSystemHealthMonitorVisibility === 'function') _syncSystemHealthMonitorVisibility(); if (typeof pollSystemHealth === 'function') void pollSystemHealth(); } catch(e) { box.innerHTML = `
    ${esc(t('error_prefix') + e.message)}
    `; } finally { if (animate && refreshBtn) { refreshBtn.style.opacity = ''; refreshBtn.disabled = false; } } } function _formatLlmWikiTimestamp(value) { if (!value) return 'Never'; try { return new Date(value).toLocaleString(); } catch (_) { return String(value); } } function _renderSystemHealthPanel() { return `
    System health
    Current VPS resource usage
    Loading…
    CPU—
    RAM—
    Disk—
    Live snapshot only; historical resource charts can build on this surface later.
    `; } function _renderLlmWikiStatus(d) { const status = d || {status:'error'}; const isReady = status.available && status.status === 'ready'; const isEmpty = status.available && status.status === 'empty'; const isError = status.status === 'error'; const badgeClass = isReady ? 'ok' : isError ? 'err' : isEmpty ? 'warn' : 'muted'; const badgeText = isReady ? 'Available' : isError ? 'Error' : isEmpty ? 'Empty' : 'Unavailable'; const rawDocsUrl = status.docs_url || 'https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/research/research-llm-wiki'; // Guard against unsafe URL schemes (e.g. js: / data:) if docs_url ever // becomes config-driven. esc() HTML-escapes but doesn't validate URL scheme. const docsUrl = /^https?:\/\//i.test(rawDocsUrl) ? rawDocsUrl : '#'; const toggleNote = status.toggle_available ? 'Toggle available from configured Hermes Agent setting.' : (status.toggle_reason || 'No stable LLM Wiki on/off config flag was detected, so this panel is read-only.'); const statusNote = isReady ? 'LLM Wiki is configured and page metadata is visible without exposing wiki content.' : isEmpty ? 'LLM Wiki exists but has no entity, concept, comparison, or query pages yet.' : isError ? `Unable to inspect LLM Wiki status${status.error ? ': ' + status.error : ''}.` : 'No LLM Wiki directory was found. Set WIKI_PATH or skills.config.wiki.path to enable status visibility.'; return `
    LLM Wiki
    Knowledge-base observability
    ${esc(badgeText)}
    ${esc(statusNote)}
    Enabled${status.enabled ? 'Yes' : 'No'}
    Entries${Number(status.entry_count || 0).toLocaleString()}
    Pages${Number(status.page_count || 0).toLocaleString()}
    raw/ files${Number(status.raw_source_count || 0).toLocaleString()}
    Last updated${esc(_formatLlmWikiTimestamp(status.last_updated))}
    Last writer${esc(status.last_writer || 'Not available')}
    `; } async function _openWikiBrowser() { const existing = document.getElementById('wikiBrowserOverlay'); if (existing) { existing.style.display = 'flex'; return; } const overlay = document.createElement('div'); overlay.id = 'wikiBrowserOverlay'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;'; overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.style.display = 'none'; }); document.addEventListener('keydown', function escHandler(e) { if (e.key === 'Escape') { overlay.style.display = 'none'; document.removeEventListener('keydown', escHandler); } }); const panel = document.createElement('div'); panel.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:8px;width:min(720px,95vw);max-height:80vh;display:flex;flex-direction:column;overflow:hidden;'; panel.innerHTML = `
    ${esc(t('wiki_browse'))}
    `; overlay.appendChild(panel); document.body.appendChild(overlay); const listEl = document.getElementById('wikiBrowserList'); const contentEl = document.getElementById('wikiBrowserContent'); const searchEl = document.getElementById('wikiBrowserSearch'); let _pages = []; function _renderWikiPageList(filter) { const q = (filter || '').toLowerCase(); const visible = q ? _pages.filter(p => p.name.toLowerCase().includes(q)) : _pages; if (!visible.length) { listEl.innerHTML = `
    ${esc(t('wiki_no_pages'))}
    `; return; } listEl.innerHTML = visible.map(p => `
    ${esc(p.name)}
    ` ).join(''); } window._wikiBrowserOpenPage = async function(path) { contentEl.innerHTML = '
    Loading...
    '; contentEl.style.display = 'block'; listEl.style.display = 'none'; try { const data = await api('/api/wiki/page?path=' + encodeURIComponent(path)); if (typeof renderMarkdownPreviewContent === 'function') { contentEl.innerHTML = '
    '; renderMarkdownPreviewContent({content: data.content, el: document.getElementById('wikiBrowserMd')}); } else { contentEl.innerHTML = '
    ' + esc(data.content) + '
    '; } } catch(e) { contentEl.innerHTML = '
    ' + esc(e.message || String(e)) + '
    '; } }; window._wikiBrowserBack = function() { contentEl.style.display = 'none'; listEl.style.display = ''; }; searchEl.addEventListener('input', () => _renderWikiPageList(searchEl.value)); try { const data = await api('/api/wiki/browse'); _pages = Array.isArray(data && data.pages) ? data.pages : []; if (!_pages.length) { listEl.innerHTML = `
    ${esc(t('wiki_no_pages'))}
    `; } else { _renderWikiPageList(''); } } catch(e) { listEl.innerHTML = `
    ${esc(e.message || String(e))}
    `; } } /** * Bucket daily token rows for chart display. * Returns rows unchanged when length <= 30 (per-day resolution). * For longer ranges, groups consecutive days into buckets: * 31–90 days → 2-day buckets * 91–180 days → 3-day buckets * 181–365 days → 8-day buckets * Result is always <= ~52 bars. * Each bucket row has: * - label: short label for axis (e.g. MM-DD or MM-DD–MM-DD) * - title: full tooltip title (e.g. 2026-01-01 – 2026-01-05) * - date: first date in bucket (used for date label slicing) * - input_tokens, output_tokens, sessions, cost: summed across bucket */ function _bucketDailyTokensForChart(rows) { if (!Array.isArray(rows) || rows.length === 0) return []; const len = rows.length; if (len <= 30) return rows; // per-day resolution for 7/30-day ranges // Target <= 75 bars; derive bucket size let bucketSize; if (len <= 90) { bucketSize = 2; } else if (len <= 180) { bucketSize = 3; } else if (len <= 365) { bucketSize = 8; // <=52 bars for 365 days (ceil(365/8)=46) } else { bucketSize = 8; // fallback for >365 (shouldn't occur in practice) } const result = []; for (let i = 0; i < len; i += bucketSize) { const slice = rows.slice(i, i + bucketSize); const input_tokens = slice.reduce((s, r) => s + Number(r.input_tokens || 0), 0); const output_tokens = slice.reduce((s, r) => s + Number(r.output_tokens || 0), 0); const cache_read_tokens = slice.reduce((s, r) => s + Number(r.cache_read_tokens || 0), 0); const sessions = slice.reduce((s, r) => s + Number(r.sessions || 0), 0); const cost = slice.reduce((s, r) => s + Number(r.cost || 0), 0); const firstDate = slice[0].date; const lastDate = slice[slice.length - 1].date; // Label: short form for axis const firstLabel = String(firstDate).slice(5); // MM-DD const lastLabel = String(lastDate).slice(5); const label = (firstDate === lastDate) ? firstLabel : (firstLabel + '–' + lastLabel); result.push({ label, title: firstDate + (firstDate !== lastDate ? ' – ' + lastDate : ''), date: firstDate, input_tokens, output_tokens, cache_read_tokens, sessions, cost, }); } return result; } function _renderSkillUsage(d) { const usage = d.usage || {}; const skillNames = d.skill_names || []; const totalInvocations = d.total_invocations || 0; const uniqueUsed = d.unique_skills_used || 0; const entries = Object.entries(usage) .map(([name, meta]) => [name, Number(meta && meta.use_count) || 0, Number(meta && meta.view_count) || 0, Number(meta && meta.patch_count) || 0]) .sort((a, b) => b[1] - a[1]) .slice(0, 10); if (!entries.length) { return `
    ${esc(t('insights_skill_usage_title'))}
    ${esc(t('insights_skill_usage_no_data'))}
    ${esc(t('insights_skill_usage_no_data_hint'))}
    `; } const rows = entries.map(([name, useCount, viewCount, patchCount]) => { const share = totalInvocations > 0 ? (useCount / totalInvocations * 100).toFixed(1) : '0.0'; return `
    ${esc(name)}${useCount}${viewCount}${patchCount}${share}%
    `; }).join(''); return `
    ${esc(t('insights_skill_usage_title'))}
    ${esc(t('insights_skill_usage_total'))}${totalInvocations.toLocaleString()}
    ${esc(t('insights_skill_usage_skills_used'))}${uniqueUsed}/${skillNames.length}
    ${esc(t('insights_skill_usage_col_skill'))}${esc(t('insights_skill_usage_col_uses'))}${esc(t('insights_skill_usage_col_views'))}${esc(t('insights_skill_usage_col_patches'))}${esc(t('insights_skill_usage_col_share'))}
    ${rows}
    `; } function _renderInsights(d, box, wikiStatus, skillUsage) { const fmtNum = n => Number(n || 0).toLocaleString(); const fmtCost = c => { const value = Number(c || 0); return value > 0 ? '$' + value.toFixed(value < 1 ? 4 : 2) : t('insights_no_cost'); }; const fmtTokens = n => { const value = Number(n || 0); return value >= 1e6 ? (value/1e6).toFixed(1) + 'M' : value >= 1e3 ? (value/1e3).toFixed(1) + 'K' : fmtNum(value); }; // Overview cards const overviewCards = [ { label: t('insights_sessions'), value: fmtNum(d.total_sessions), icon: li('message-square', 18) }, { label: t('insights_messages'), value: fmtNum(d.total_messages), icon: li('hash', 18) }, { label: t('insights_tokens'), value: fmtTokens(d.total_tokens), icon: li('cpu', 18) }, { label: t('insights_cost'), value: fmtCost(d.total_cost), icon: li('dollar-sign', 18) }, ]; // Daily token trend — bucket long ranges to avoid horizontal overflow const dailyTokens = Array.isArray(d.daily_tokens) ? d.daily_tokens : []; const chartRows = _bucketDailyTokensForChart(dailyTokens); let dailyHtml = ''; if (chartRows.length) { const maxDailyTokens = Math.max(...chartRows.map(r => Number(r.input_tokens || 0) + Number(r.output_tokens || 0)), 1); const labelEvery = Math.max(Math.ceil(chartRows.length / 7), 1); dailyHtml = `
    ${esc(t('insights_daily_tokens'))}
    ` + chartRows.map((r, idx) => { const input = Number(r.input_tokens || 0); const output = Number(r.output_tokens || 0); const inputPct = Math.max((input / maxDailyTokens) * 100, input ? 2 : 0).toFixed(1); const outputPct = Math.max((output / maxDailyTokens) * 100, output ? 2 : 0).toFixed(1); const showLabel = idx === 0 || idx === chartRows.length - 1 || idx % labelEvery === 0; const titleDate = r.title || r.date; const cacheRead = Number(r.cache_read_tokens || 0); // Bounded daily cache hit rate: reads / (input + reads), 0-100%. const cacheDenom = input + cacheRead; const cacheStr = (cacheRead > 0 && cacheDenom > 0) ? ` · ${Math.min(100, Math.round((cacheRead / cacheDenom) * 100))}% ${t('insights_model_cache')}` : ''; const title = `${titleDate} · ${fmtTokens(input)} ${t('insights_input_tokens')} · ${fmtTokens(output)} ${t('insights_output_tokens')}${cacheStr} · ${fmtCost(r.cost)} · ${fmtNum(r.sessions)} ${t('insights_sessions')}`; const labelText = r.label !== undefined ? r.label : String(r.date).slice(5); return `
    ${showLabel ? esc(labelText) : ''}
    `; }).join('') + `
    ${esc(t('insights_input_tokens'))}${esc(t('insights_output_tokens'))}
    `; } else { dailyHtml = `
    ${esc(t('insights_daily_tokens'))}
    ${esc(t('insights_no_usage_data'))}
    `; } // Models table let modelsHtml = ''; if (d.models && d.models.length) { modelsHtml = `
    ${esc(t('insights_models'))}
    ${esc(t('insights_model_name'))}${esc(t('insights_model_sessions'))}${esc(t('insights_model_tokens'))}${esc(t('insights_model_cache'))}${esc(t('insights_model_cost'))}${esc(t('insights_model_share'))}
    ` + d.models.map(m => { const share = Number(m.cost_share || m.token_share || m.session_share || 0); const title = `${m.model} · ${fmtTokens(m.input_tokens)} ${t('insights_input_tokens')} · ${fmtTokens(m.output_tokens)} ${t('insights_output_tokens')}`; const cachePct = (m.cache_hit_percent === null || m.cache_hit_percent === undefined) ? null : Number(m.cache_hit_percent); const cacheCell = cachePct === null ? '—' : `${cachePct}%`; return `
    ${esc(m.model)}${fmtNum(m.sessions)}${fmtTokens(m.total_tokens || 0)}${cacheCell}${fmtCost(m.cost)}${share}%
    `; }).join('') + `
    `; } else { modelsHtml = `
    ${esc(t('insights_models'))}
    ${esc(t('insights_no_usage_data'))}
    `; } const modelHealthHtml = _renderStaticModelHealthTable(); // Activity by day of week let dowHtml = ''; if (d.activity_by_day) { const maxDow = Math.max(...d.activity_by_day.map(x => x.sessions), 1); dowHtml = `
    ${esc(t('insights_activity_by_day'))}
    ` + d.activity_by_day.map(r => { const pct = (r.sessions / maxDow * 100).toFixed(0); return `
    ${r.day}
    ${r.sessions}
    `; }).join('') + `
    `; } // Activity by hour let hodHtml = ''; if (d.activity_by_hour) { const maxHod = Math.max(...d.activity_by_hour.map(x => x.sessions), 1); const peakHour = d.activity_by_hour.reduce((a, b) => b.sessions > a.sessions ? b : a, {hour:0,sessions:0}); hodHtml = `
    ${esc(t('insights_activity_by_hour'))} ${esc(t('insights_peak_hour').replace('{hour}', peakHour.hour + ':00'))}
    ` + d.activity_by_hour.map(r => { const pct = (r.sessions / maxHod * 100).toFixed(0); const isPeak = r.hour === peakHour.hour && peakHour.sessions > 0; return `
    ${String(r.hour).padStart(2,'0')}
    ${r.sessions}
    `; }).join('') + `
    `; } // Token breakdown const tokenCards = `
    ${esc(t('insights_token_breakdown'))}
    ${esc(t('insights_input_tokens'))} ${fmtTokens(d.total_input_tokens)}
    ${esc(t('insights_output_tokens'))} ${fmtTokens(d.total_output_tokens)}
    ${esc(t('insights_total'))} ${fmtTokens(d.total_tokens)}
    `; box.innerHTML = ` ${_renderSystemHealthPanel()} ${_renderLlmWikiStatus(wikiStatus)} ${_renderSkillUsage(skillUsage)}
    ${overviewCards.map(c => `
    ${c.icon}
    ${c.value}
    ${esc(c.label)}
    `).join('')}
    ${dailyHtml} ${modelHealthHtml}
    ${tokenCards} ${modelsHtml}
    ${dowHtml} ${hodHtml}
    ${esc(t('insights_footer').replace('{days}', d.period_days))}
    `; } 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' + (skill.disabled ? ' disabled' : ''); el.style.display = collapsed ? 'none' : ''; const isDisabled = skill.disabled || false; const toggle = document.createElement('span'); toggle.className = 'skill-toggle' + (isDisabled ? '' : ' enabled'); toggle.title = isDisabled ? t('skill_disabled') : t('skill_enabled'); toggle.addEventListener('click', (ev) => { ev.stopPropagation(); toggleSkill(skill.name, !isDisabled); }); const nameEl = document.createElement('span'); nameEl.className = 'skill-name'; nameEl.textContent = skill.name; const descEl = document.createElement('span'); descEl.className = 'skill-desc'; descEl.textContent = skill.description || ''; el.append(toggle, nameEl, descEl); el.onclick = () => openSkill(skill.name, el); sec.appendChild(el); } box.appendChild(sec); } } function filterSkills() { if (_skillsData) renderSkills(_skillsData); } async function toggleSkill(name, currentlyEnabled) { const newEnabled = !currentlyEnabled; try { const result = await api('/api/skills/toggle', { method: 'POST', body: JSON.stringify({ name, enabled: newEnabled }) }); if (result && result.ok) { if (_skillsData) { const skill = _skillsData.find(s => s.name === name); if (skill) skill.disabled = !newEnabled; } if(typeof window!=='undefined'&&typeof window.invalidateSlashSkillCaches==='function') window.invalidateSlashSkillCaches(); renderSkills(_skillsData || []); } else { setStatus((result && result.error) || t('skill_toggle_failed')); } } catch(e) { setStatus(t('skill_toggle_failed') + e.message); } } // 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 _skillMarkdownHtml(markdown) { return `
    ${renderMd(markdown || '')}
    `; } function _enhanceSkillMarkdown(root) { if (!root) return; requestAnimationFrame(() => { const mdRoot = root.querySelector('.preview-md') || root; if (typeof highlightCode === 'function') highlightCode(mdRoot); if (typeof renderKatexBlocks === 'function') renderKatexBlocks(mdRoot); }); } 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 += _skillMarkdownHtml(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}
    `; _enhanceSkillMarkdown(body); 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 _renderSkillError(name, message) { const title = $('skillDetailTitle'); const body = $('skillDetailBody'); const empty = $('skillDetailEmpty'); if (title) title.textContent = name; if (body) { body.innerHTML = `
    ${esc(message || t('skill_load_failed'))}
    `; body.style.display = ''; } if (empty) empty.style.display = 'none'; _currentSkillDetail = null; _skillMode = 'empty'; _setSkillHeaderButtons('empty'); } function _setSkillHeaderButtons(mode) { const header = $('mainSkills') && $('mainSkills').querySelector('.main-view-header'); 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') { if (header) header.style.display = 'flex'; show(editBtn); show(delBtn); hide(cancelBtn); hide(saveBtn); } else if (mode === 'create' || mode === 'edit') { if (header) header.style.display = 'flex'; hide(editBtn); hide(delBtn); show(cancelBtn); show(saveBtn); } else { if (header) header.style.display = 'none'; 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)}`); if (data && (data.success === false || data.error)) { const message = data.error || t('skill_load_failed'); _renderSkillError(name, message); setStatus(t('skill_load_failed') + message); return; } _currentSkillDetail = { name, content: data.content || '', linked_files: data.linked_files || {} }; _renderSkillDetail(name, data.content || '', data.linked_files || {}); _closeMobileSidebarAfterPanelSelection(); } 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)}`); if (data && data.error) { _renderSkillError(skillName, data.error); setStatus(t('skill_file_load_failed') + data.error); return; } 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 = `
    ${_skillMarkdownHtml(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) _enhanceSkillMarkdown(body); else 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 }); // Mobile: the new-skill form lives in the main view, which is covered by the // full-screen sidebar drawer. Close the drawer so the form is visible (mirror // openSkillDetail's behaviour); no-op on desktop. _closeMobileSidebarAfterPanelSelection(); } 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; if(typeof window!=='undefined'&&typeof window.invalidateSlashSkillCaches==='function') window.invalidateSlashSkillCaches(); _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; if(typeof window!=='undefined'&&typeof window.invalidateSlashSkillCaches==='function') window.invalidateSlashSkillCaches(); _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 _notesSourcesData = null; let _notesSearchResults = []; let _notesSelectedSource = 'joplin'; let _notesPreviewNote = null; let _notesSearchError = ''; let _notesSearchLoading = false; let _currentMemorySection = null; // 'memory' | 'user' | 'soul' | 'project_context' | 'external_notes' 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' }, { key: 'soul', labelKey: 'agent_soul', emptyKey: 'no_soul_yet', iconKey: 'sparkles' }, { key: 'project_context', label: 'Project Context', empty: 'No project context file found for this workspace.', iconKey: 'file-text', readOnly: true }, { key: 'external_notes', labelKey: 'external_notes_sources', emptyKey: 'external_notes_empty', iconKey: 'book-open' }, ]; function _memorySectionMeta(key) { return MEMORY_SECTIONS.find(s => s.key === key) || MEMORY_SECTIONS[0]; } function _memorySectionLabel(meta) { if (meta.label) return meta.label; return t(meta.labelKey); } function _memorySectionEmpty(meta) { if (meta.empty) return meta.empty; return t(meta.emptyKey); } function _memorySectionContent(key) { if (!_memoryData) return ''; if (key === 'user') return _memoryData.user || ''; if (key === 'soul') return _memoryData.soul || ''; if (key === 'project_context') return _memoryData.project_context || ''; return _memoryData.memory || ''; } function _memorySectionMtime(key) { if (!_memoryData) return 0; if (key === 'user') return _memoryData.user_mtime || 0; if (key === 'soul') return _memoryData.soul_mtime || 0; if (key === 'project_context') return _memoryData.project_context_mtime || 0; return _memoryData.memory_mtime || 0; } function _memorySectionPath(key) { if (!_memoryData) return ''; if (key === 'user') return _memoryData.user_path || ''; if (key === 'soul') return _memoryData.soul_path || ''; if (key === 'project_context') return _memoryData.project_context_path || ''; if (key === 'memory') return _memoryData.memory_path || ''; return ''; } function _setMemoryHeaderButtons(mode) { const header = $('mainMemory') && $('mainMemory').querySelector('.main-view-header'); const show = b => b && (b.style.display = ''); const hide = b => b && (b.style.display = 'none'); const editBtn = $('btnEditMemoryDetail'); const cancelBtn = $('btnCancelMemoryDetail'); const saveBtn = $('btnSaveMemoryDetail'); const meta = _memorySectionMeta(_currentMemorySection); if (mode === 'read') { // Any read view has a populated title → header must be visible. Only the // Edit affordance is gated on the section being editable (read-only // sections like Project Context / External Notes still show the header). if (header) header.style.display = 'flex'; if (_currentMemorySection !== 'external_notes' && !meta.readOnly) show(editBtn); else hide(editBtn); hide(cancelBtn); hide(saveBtn); } else if (mode === 'edit') { if (header) header.style.display = 'flex'; hide(editBtn); show(cancelBtn); show(saveBtn); } else { if (header) header.style.display = 'none'; hide(editBtn); hide(cancelBtn); hide(saveBtn); } } function _renderExternalNotesSources() { const title = $('memoryDetailTitle'); const body = $('memoryDetailBody'); const empty = $('memoryDetailEmpty'); if (!title || !body) return; title.textContent = t('external_notes_sources'); const data = _notesSourcesData || {}; const sources = Array.isArray(data.sources) ? data.sources : []; const recall = data.automatic_recall_unchanged !== false ? `
    ${esc(t('external_notes_auto_recall_hint'))}
    ` : ''; // Same withheld-runtime state as the MCP panel: sources still list, but their // live status/tools are hidden until the profile's runtime scope is confirmed. const scopeNotice = data.runtime_scope === 'unavailable' ? `
    ${esc(t('mcp_runtime_scope_unavailable'))}
    ` : ''; if (!sources.length) { body.innerHTML = `
    ${recall}${scopeNotice}
    ${esc(t('external_notes_empty'))}
    `; } else { const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0]; _notesSelectedSource = (selected && selected.name) || 'joplin'; const sourceOptions = sources.map(src => ``).join(''); const recentAiNotes = Array.isArray(data.recent_ai_notes) ? data.recent_ai_notes : []; const recentAiHtml = recentAiNotes.length ? `
    ${li('bot', 14)}${esc(t('external_notes_recent_ai'))}${esc(t('external_notes_auto'))}
    ${recentAiNotes.map(note => { const updated = note.updated_time ? new Date(Number(note.updated_time)).toLocaleString() : ''; return ``; }).join('')}
    ` : ''; const searchError = _notesSearchError ? `
    ${esc(_notesSearchError)}
    ` : ''; const resultHtml = _notesSearchResults.length ? `
    ${_notesSearchResults.map(note => ``).join('')}
    ` : `
    ${esc(t('external_notes_search_empty'))}
    `; const previewHtml = _notesPreviewNote ? `
    ${esc(_notesPreviewNote.title||'Untitled')}${esc(_notesPreviewNote.source||_notesSelectedSource)}
    ${renderMd(_notesPreviewNote.body||'')}
    ` : ''; const cards = sources.map(src => { const status = src.active ? t('source_active') : (src.status || t('source_configured')); const tools = Array.isArray(src.tools) ? src.tools : []; const hintHtml = src.tool_source === 'configured_hint' ? `
    ${esc(t('external_notes_configured_hint'))}
    ` : ''; const toolHtml = tools.length ? `
      ${tools.map(tool => `
    • ${esc(tool.name||'')}${tool.description?` — ${esc(tool.description)}`:''}
    • `).join('')}
    ` : `
    ${esc(t('external_notes_no_tools'))}
    `; return `
    ${esc(src.label||src.name||'')}${esc(status)}
    ${esc(t('external_notes_tool_count', src.tool_count||0))}
    ${hintHtml} ${toolHtml}
    `; }).join(''); const searchUi = `
    ${searchError} ${resultHtml}
    `; body.innerHTML = `
    ${recall}${scopeNotice}${recentAiHtml}${searchUi}${previewHtml}${cards}
    `; } body.style.display = ''; if (empty) empty.style.display = 'none'; _memoryMode = 'read'; _setMemoryHeaderButtons('read'); } function _renderMemoryDetail(section) { if (section === 'external_notes') { _renderExternalNotesSources(); return; } const meta = _memorySectionMeta(section); const title = $('memoryDetailTitle'); const body = $('memoryDetailBody'); const empty = $('memoryDetailEmpty'); if (!title || !body) return; title.textContent = _memorySectionLabel(meta); const content = _memorySectionContent(section); const mtime = _memorySectionMtime(section); const mtimeStr = mtime ? new Date(mtime * 1000).toLocaleString() : ''; const mtimeHtml = mtimeStr ? `
    ${esc(mtimeStr)}
    ` : ''; const path = _memorySectionPath(section); const fileName = section === 'project_context' && _memoryData ? (_memoryData.project_context_name || (path.split(/[\\/]/).pop() || '')) : (path.split(/[\\/]/).pop() || ''); const pathHtml = path ? `
    ${esc(fileName)} · ${esc(path)}
    ` : ''; const shadowed = section === 'project_context' && _memoryData && Array.isArray(_memoryData.project_context_shadowed) ? _memoryData.project_context_shadowed : []; const shadowedHtml = shadowed.length ? `
    ${esc(shadowed.map(item => `${item.name || 'Context file'} present, shadowed by ${item.shadowed_by || fileName || 'active context'}`).join('; '))}
    ` : ''; const inner = content ? `
    ${renderMd(content)}
    ` : `
    ${esc(_memorySectionEmpty(meta))}
    `; body.innerHTML = `
    ${pathHtml}${mtimeHtml}${shadowedHtml}${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 = _memorySectionLabel(meta); 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(); } async function loadNotesSources(force) { if (_notesSourcesData && !force) return _notesSourcesData; try { _notesSourcesData = await api('/api/notes/sources'); } catch (e) { _notesSourcesData = {sources: [], automatic_recall_unchanged: true, error: e && e.message ? e.message : String(e)}; } return _notesSourcesData; } function selectExternalNotesSource(source) { _notesSelectedSource = source || 'joplin'; _notesSearchResults = []; _notesPreviewNote = null; _notesSearchError = ''; _renderExternalNotesSources(); } async function searchExternalNotes() { const input = $('externalNotesQuery'); const sourceEl = $('externalNotesSource'); const q = input ? input.value.trim() : ''; _notesSelectedSource = sourceEl ? sourceEl.value : (_notesSelectedSource || 'joplin'); _notesPreviewNote = null; _notesSearchError = ''; if (!q) { _notesSearchResults = []; _renderExternalNotesSources(); return; } _notesSearchLoading = true; _renderExternalNotesSources(); try { const data = await api(`/api/notes/search?source=${encodeURIComponent(_notesSelectedSource)}&q=${encodeURIComponent(q)}&limit=20`); _notesSearchResults = Array.isArray(data.results) ? data.results : []; _notesSearchError = data.error || ''; } catch (e) { _notesSearchResults = []; _notesSearchError = e && e.message ? e.message : String(e); } finally { _notesSearchLoading = false; _renderExternalNotesSources(); const nextInput = $('externalNotesQuery'); if (nextInput) nextInput.value = q; } } async function previewExternalNote(source, id) { _notesSearchError = ''; try { const data = await api(`/api/notes/item?source=${encodeURIComponent(source||_notesSelectedSource)}&id=${encodeURIComponent(id||'')}`); _notesPreviewNote = data && data.note ? data.note : null; } catch (e) { _notesPreviewNote = null; _notesSearchError = e && e.message ? e.message : String(e); } _renderExternalNotesSources(); } async function openMemorySection(section, el) { if (section === 'external_notes' && _memoryData && !_memoryData.external_notes_enabled) return; _currentMemorySection = section; document.querySelectorAll('#memoryPanel .side-menu-item').forEach(e => e.classList.remove('active')); if (el) el.classList.add('active'); if (section === 'external_notes') { await loadNotesSources(false); } _renderMemoryDetail(section); _closeMobileSidebarAfterPanelSelection(); } function editCurrentMemory() { const meta = _memorySectionMeta(_currentMemorySection); if (!_currentMemorySection || _currentMemorySection === 'external_notes' || meta.readOnly) 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; if (_memorySectionMeta(_currentMemorySection).readOnly) 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 mobileAction=$('composerMobileWorkspaceAction'); const mobileLabel=$('composerMobileWorkspaceLabel'); const composerDropdown=$('composerWsDropdown'); if(!hasWorkspace && composerDropdown) _setWorkspaceDropdownOpenState(composerDropdown,false); // 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(mobileLabel) mobileLabel.textContent=S._bootReady?label:''; const composerExpanded=!!(composerDropdown&&composerDropdown.classList.contains('open')); if(composerChip){ composerChip.disabled=!hasWorkspace; composerChip.title=hasWorkspace?ws:t('no_workspace'); composerChip.setAttribute('aria-label',hasWorkspace?t('workspace_switcher_aria',label):t('no_workspace')); composerChip.setAttribute('aria-expanded',composerExpanded?'true':'false'); composerChip.classList.toggle('active',composerExpanded); } if(mobileAction){ mobileAction.title=hasWorkspace?ws:t('no_workspace'); mobileAction.setAttribute('aria-label',hasWorkspace?t('workspace_switcher_aria',label):t('no_workspace')); mobileAction.setAttribute('aria-expanded',composerExpanded?'true':'false'); mobileAction.classList.toggle('active',composerExpanded); } } async function loadWorkspaceList(){ try{ const data = await api('/api/workspaces'); if(typeof syncTerminalBackendState==='function') syncTerminalBackendState(data); _workspaceList = data.workspaces || []; syncWorkspaceDisplays(); if(typeof syncTerminalButton==='function') syncTerminalButton(); return data; }catch(e){ return {workspaces:[], last:''}; } } function _setWorkspaceDropdownOpenState(dd,open){ if(!dd)return; dd.classList.toggle('open',!!open); dd.hidden=!open; dd.setAttribute('aria-hidden',open?'false':'true'); if(open){ try{dd.inert=false;}catch(_){} dd.removeAttribute('inert'); }else{ try{dd.inert=true;}catch(_){} dd.setAttribute('inert',''); } } function _getComposerWorkspaceFocusTarget(){ const panel=(typeof $==='function')?$('composerMobileConfigPanel'):null; const mobileAction=(typeof $==='function')?$('composerMobileWorkspaceAction'):null; if(panel&&panel.classList.contains('open')&&mobileAction&&!mobileAction.disabled) return mobileAction; return (typeof $==='function')?$('composerWorkspaceChip'):null; } function _focusComposerWorkspaceTarget(target){ if(target&&!target.disabled&&typeof target.focus==='function'){ try{target.focus({preventScroll:true});} catch(_){target.focus();} } } function _shouldRestoreComposerWorkspaceFocus(dd){ if(typeof document==='undefined') return true; const active=document.activeElement; if(!active||active===document.body) return true; return !!(dd&&dd.contains(active)); } 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 mobileAction=$('composerMobileWorkspaceAction'); const panel=$('composerMobileConfigPanel'); const footer=document.querySelector('.composer-footer'); // While the mobile config panel is open, anchor to #composerMobileWorkspaceAction instead of only the desktop workspace chip. const anchor=(panel&&panel.classList.contains('open')&&mobileAction)?mobileAction:chip; if(!dd||!anchor||!footer)return; const chipRect=anchor.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 trigger=_profileDropdownTrigger||$('profileChip'); if(!dd||!trigger)return; const rect=trigger.getBoundingClientRect(); const gap=4; const ddW=dd.offsetWidth||260; // Decide direction: below for titlebar, above for composer const openBelow=trigger===document.getElementById('titlebarProfileBtn'); // Horizontal: center on trigger, clamp to viewport let left=rect.left+(rect.width/2)-(ddW/2); left=Math.max(8, Math.min(left, window.innerWidth-ddW-8)); dd.style.left=left+'px'; // Vertical if(openBelow){ dd.style.bottom=''; // clear any stale bottom from a prior composer-chip open dd.style.top=(rect.bottom+gap)+'px'; dd.classList.add('open-below'); }else{ dd.style.top=''; dd.style.bottom=''; // clear fixed top/bottom dd.style.bottom=(window.innerHeight-rect.top+gap)+'px'; dd.classList.remove('open-below'); } } function renderWorkspaceDropdownInto(dd, workspaces, currentWs){ if(!dd)return; dd.innerHTML=''; // ── Search row ────────────────────────────────────────────────────────── const searchRow=document.createElement('div'); searchRow.className='ws-search-row'; searchRow.innerHTML=``; const si=searchRow.querySelector('.ws-search-input'); const sc=searchRow.querySelector('.ws-search-clear'); dd.appendChild(searchRow); // Render in the server's stored order — the same order shown in the // Workspaces settings panel (user-controlled via drag-and-drop reorder, // with the default "Home" workspace first). No client-side re-sorting. // Shallow copy so later in-place mutations can't touch the caller's array. const sorted=[...workspaces]; const listContainer=document.createElement('div'); listContainer.className='ws-list-container'; dd.appendChild(listContainer); // Pre-create noResults element so filterWs can reference it safely from the start. const noResults=document.createElement('div'); noResults.className='ws-no-results'; noResults.textContent=t('ws_no_results')||'No workspaces found'; noResults.style.display='none'; function filterWs(term){ term=(term||'').trim().toLowerCase(); let visible=0; const opts=listContainer.querySelectorAll('.ws-opt'); for(const opt of opts){ const name=(opt.dataset.name||'').toLowerCase(); const path=(opt.dataset.path||'').toLowerCase(); const show=!term||name.includes(term)||path.includes(term); opt.style.display=show?'':'none'; if(show) visible++; } noResults.style.display=visible?'none':''; } function renderList(){ listContainer.innerHTML=''; for(const w of sorted){ const opt=document.createElement('div'); opt.className='ws-opt'+(w.path===currentWs?' active':''); opt.dataset.name=w.name||''; opt.dataset.path=w.path||''; opt.innerHTML=`${esc(w.name)}${esc(w.path)}`; opt.onclick=()=>switchToWorkspace(w.path,w.name); listContainer.appendChild(opt); } listContainer.appendChild(noResults); } renderList(); filterWs(''); si.addEventListener('input',()=>{ filterWs(si.value); }); sc.addEventListener('click',()=>{ si.value=''; filterWs(''); si.focus(); }); // ── Footer actions ──────────────────────────────────────────────────────── dd.appendChild(document.createElement('div')).className='ws-divider'; dd.appendChild(_renderWorkspaceAction( t('workspace_new_worktree_conversation'), t('workspace_new_worktree_conversation_meta'), li('git-branch',12), async()=>{ closeWsDropdown(); try{ await newSession(false,{worktree:true}); await renderSessionList(); const msg=$('msg'); if(msg)msg.focus(); showToast(t('workspace_worktree_created')); }catch(e){ showToast(t('workspace_worktree_failed')+(e&&e.message?e.message:e),'error'); } } )); 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?.workspace||S._profileDefaultWorkspace||data.last||''); _setWorkspaceDropdownOpenState(dd,true); }); } } function toggleComposerWsDropdown(){ const dd=$('composerWsDropdown'); const chip=$('composerWorkspaceChip'); const mobileAction=$('composerMobileWorkspaceAction'); const panel=$('composerMobileConfigPanel'); const usingMobileAction=!!(panel&&panel.classList.contains('open')&&mobileAction); if(!dd||(!usingMobileAction&&(!chip||chip.disabled)))return; const open=dd.classList.contains('open'); if(open){closeWsDropdown();} else{ closeProfileDropdown(); if(typeof closeModelDropdown==='function') closeModelDropdown(); if(typeof closeReasoningDropdown==='function') closeReasoningDropdown(); loadWorkspaceList().then(data=>{ renderWorkspaceDropdownInto(dd, data.workspaces, S.session?.workspace||S._profileDefaultWorkspace||data.last||''); _setWorkspaceDropdownOpenState(dd,true); _positionComposerWsDropdown(); if(chip){ chip.classList.add('active'); chip.setAttribute('aria-expanded','true'); } if(mobileAction){ mobileAction.classList.add('active'); mobileAction.setAttribute('aria-expanded','true'); } }); } } function closeWsDropdown(){ const dd=$('wsDropdown'); const composerDd=$('composerWsDropdown'); const composerChip=$('composerWorkspaceChip'); const mobileAction=$('composerMobileWorkspaceAction'); if(dd)_setWorkspaceDropdownOpenState(dd,false); if(composerDd)_setWorkspaceDropdownOpenState(composerDd,false); if(composerChip){ composerChip.classList.remove('active'); composerChip.setAttribute('aria-expanded','false'); } if(mobileAction){ mobileAction.classList.remove('active'); mobileAction.setAttribute('aria-expanded','false'); } } document.addEventListener('click',e=>{ if( !e.target.closest('#composerWorkspaceChip') && !e.target.closest('#composerMobileWorkspaceAction') && !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}
    ${esc(t('checkpoint_title'))}
    ${esc(t('checkpoint_loading'))}
    `; body.style.display = ''; if (empty) empty.style.display = 'none'; _workspaceMode = 'read'; _setWorkspaceHeaderButtons('read', ws); _loadCheckpoints(ws.path); } function _setWorkspaceHeaderButtons(mode, ws){ const header = $('mainWorkspaces') && $('mainWorkspaces').querySelector('.main-view-header'); 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') { if (header) header.style.display = 'flex'; 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') { if (header) header.style.display = 'flex'; hide(actBtn); hide(editBtn); hide(delBtn); show(cancelBtn); show(saveBtn); } else { if (header) header.style.display = 'none'; [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); _closeMobileSidebarAfterPanelSelection(); } 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 }); // Mobile: the add-space form lives in the main view, which is covered by the // full-screen sidebar drawer. Close the drawer so the form is visible (mirror // openWorkspaceDetail's behaviour); no-op on desktop. _closeMobileSidebarAfterPanelSelection(); } 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{ // System-minted session (#6022): worktree:false is explicit so a config // worktree default can't leak a worktree from a workspace prompt. const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws,worktree:false})}); if(r&&r.session){S._pendingSessionToolsets=null;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{ // System-minted session (#6022): explicit worktree:false — a workspace // switch from a blank page is not deliberate New Chat intent. const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws,worktree:false})}); if(r&&r.session){S._pendingSessionToolsets=null;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; } // Workspace mutation during a live turn would desync the active stream context. if(S.busy){ showToast(t('workspace_busy_switch')); return; } // #5473 (opt-in, default off): treat switching to a DIFFERENT workspace as a // new-chat boundary instead of mutating the current session in place. A // workspace switch changes the project-context files the agent loaded, so // reusing the session would carry stale cross-workspace context. Only fires // when: the setting is on, the target workspace actually differs from the // current one, and the current conversation has real messages worth keeping on // its original workspace. Same-workspace selection stays an in-place refresh. if( window._newChatOnWorkspaceSwitch===true && S.session && S.session.workspace && path && path!==S.session.workspace && Array.isArray(S.messages) && S.messages.length>0 ){ 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(); } closeWsDropdown(); // Bind the new chat to the selected workspace via the one-shot flag newSession() reads. S._profileSwitchWorkspace=path; if(typeof newSession==='function') await newSession(false); showToast(t('workspace_switched_new_chat',name||getWorkspaceFriendlyName(path))); 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(); } const composerDd=(typeof $==='function')?$('composerWsDropdown'):null; const restoreComposerFocusTarget=(composerDd&&composerDd.classList.contains('open')&&typeof _getComposerWorkspaceFocusTarget==='function') ? _getComposerWorkspaceFocusTarget() : null; try{ closeWsDropdown(); // Invalidate any older /api/list response before the explicit workspace // mutation. Otherwise a delayed recovery response for this same session can // overwrite the user's newer selection and reject this switch's fresh tree. if(typeof bumpWorkspaceTreeGen==='function')bumpWorkspaceTreeGen(); await api('/api/session/update',{method:'POST',body:JSON.stringify({ session_id:S.session.session_id, workspace:path, model:S.session.model, model_provider:S.session.model_provider||null })}); 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; S._pendingSessionToolsets=null; syncTopbar(); if( restoreComposerFocusTarget&& typeof _shouldRestoreComposerWorkspaceFocus==='function'&& _shouldRestoreComposerWorkspaceFocus(composerDd)&& typeof _focusComposerWorkspaceTarget==='function' ) _focusComposerWorkspaceTarget(restoreComposerFocusTarget); await loadDir('.'); if (_currentPanel === 'memory') await loadMemory(true); showToast(t('workspace_switched_to',name||getWorkspaceFriendlyName(path))); }catch(e){setStatus(t('switch_failed')+e.message);} } // ── Profile panel + dropdown ── let _profilesCache = null; let _profileDropdownFetchPromise = null; let _profileDropdownCacheLoadedFromStorage = false; const PROFILE_DROPDOWN_CACHE_KEY = 'hermes-webui-profile-dropdown-cache-v1'; const PROFILE_DROPDOWN_CACHE_TTL_MS = 5 * 60 * 1000; let _profileSwitchGeneration = 0; let _profileDropdownTrigger = null; // tracks which element triggered the dropdown let _profileDropdownOpenGeneration = 0; function _profileDropdownClearStoredCache(){ try{localStorage.removeItem(PROFILE_DROPDOWN_CACHE_KEY);}catch(_){} } function _profileDropdownDataCacheUsable(data){ return !!( data && Array.isArray(data.profiles) && data.profiles.length && data.profiles.every(p=> p && typeof p.name==='string' && // Renderer-read fields must be safe types: renderProfileDropdown / // renderProfilesPanel call p.model.split('/') guarded only by truthiness, // so a poisoned cached row like {name:"x", model:{}} would pass a // name-only check yet throw synchronously on dropdown open (bricking // profile switching). Reject rows whose model is a non-string truthy value. (p.model==null || typeof p.model==='string') ) ); } function _profileDropdownCacheUsable(data){ return !!(_profileDropdownDataCacheUsable(data) && data.single_profile_mode !== true); } function _profileDropdownReadStoredCache(){ if(_profileDropdownCacheLoadedFromStorage) return _profileDropdownCacheUsable(_profilesCache) ? _profilesCache : null; _profileDropdownCacheLoadedFromStorage = true; try{ const raw=localStorage.getItem(PROFILE_DROPDOWN_CACHE_KEY); if(!raw) return null; const parsed=JSON.parse(raw); if(!parsed || typeof parsed.ts!=='number' || !parsed.data) { _profileDropdownClearStoredCache(); return null; } if(Date.now()-parsed.ts>PROFILE_DROPDOWN_CACHE_TTL_MS) { _profileDropdownClearStoredCache(); return null; } if(!_profileDropdownCacheUsable(parsed.data)) { _profileDropdownClearStoredCache(); return null; } _profilesCache = parsed.data; return _profilesCache; }catch(_){_profileDropdownClearStoredCache();return null;} } function _profileDropdownWriteStoredCache(data){ if(!_profileDropdownCacheUsable(data)) { _profileDropdownClearStoredCache(); return; } try{localStorage.setItem(PROFILE_DROPDOWN_CACHE_KEY, JSON.stringify({ts:Date.now(), data}));}catch(_){} } function _profileDropdownBestCachedData(){ if(_profileDropdownCacheUsable(_profilesCache)) return _profilesCache; if(_profileDropdownDataCacheUsable(_profilesCache)) return null; _profilesCache = null; return _profileDropdownReadStoredCache(); } function _profileDropdownFetchFresh(){ if(_profileDropdownFetchPromise) return _profileDropdownFetchPromise; _profileDropdownFetchPromise = api('/api/profiles', {timeoutToast:false}).then(data=>{ if(_profileDropdownDataCacheUsable(data)) _profilesCache = data; _profileDropdownWriteStoredCache(data); return data; }).finally(()=>{ _profileDropdownFetchPromise = null; }); return _profileDropdownFetchPromise; } function _warmProfileDropdownCache(){ _profileDropdownBestCachedData(); _profileDropdownFetchFresh().catch(()=>{}); } if(typeof window!=='undefined'){ window.addEventListener('load',()=>{ setTimeout(()=>{ if(typeof document==='undefined'||!document.hidden) _warmProfileDropdownCache(); },1200); },{once:true}); } function _renderProfileDropdownLoading(){ const dd=$('profileDropdown'); if(!dd)return; dd.innerHTML=`
    ${esc(t('loading')||'Loading...')}
    `; } function _openProfileDropdownShell(){ const dd=$('profileDropdown'); if(!dd)return; dd.classList.add('open'); _positionProfileDropdown(); const chip=$('profileChip'); if(chip && _profileDropdownTrigger===chip) chip.classList.add('active'); const tbtn=$('titlebarProfileBtn'); if(tbtn && _profileDropdownTrigger===tbtn) tbtn.classList.add('active'); } async function _profileSwitchPanelLoad(){ // Cross-profile cron visibility is an active-profile opt-in; never carry it // into the next profile when the Tasks panel wasn't the visible panel. _showAllCronProfiles = false; _cronOtherProfileCount = 0; _cronPreFormDetail = null; _editingCronId = null; _cronIsDuplicate = false; _clearCronDetail(); if (_currentPanel === 'skills') await loadSkills(); if (_currentPanel === 'memory') await loadMemory(); if (_currentPanel === 'tasks') await loadCrons(); if (_currentPanel === 'kanban') await loadKanban(); if (_currentPanel === 'profiles') await loadProfilesPanel(); if (_currentPanel === 'workspaces') await loadWorkspacesPanel(); } function _refreshProfileSwitchBackground(gen){ window._modelDropdownReady=null; if (typeof window._ensureModelDropdownReady === 'function') { Promise.resolve(window._ensureModelDropdownReady()).catch(()=>{}); } Promise.resolve(loadWorkspaceList()).then(()=>{ if (gen !== _profileSwitchGeneration) return; if (S.session && typeof syncTopbar === 'function') syncTopbar(); }).catch(()=>{}); // Reconcile per-profile sidebar tab visibility. hidden_tabs is a per-profile // appearance setting; without this fetch, Profile A's hidden-tabs choice // would remain in effect under Profile B until the user opens Settings. // Stage-394 follow-up to #2636 deep review. Promise.resolve(api('/api/settings')).then(function(s){ if (gen !== _profileSwitchGeneration) return; var hidden = (s && Array.isArray(s.hidden_tabs)) ? s.hidden_tabs : []; hidden = hidden.filter(function(x){ return typeof x === 'string' && x.trim(); }); var order = (s && Array.isArray(s.tab_order)) ? s.tab_order : []; order = order.filter(function(x){ return typeof x === 'string' && x.trim(); }); if (typeof _setHiddenTabs === 'function') _setHiddenTabs(hidden); if (typeof _setTabOrder === 'function') _setTabOrder(order); if (typeof _applyTabOrder === 'function') _applyTabOrder(order); if (typeof _applyTabVisibility === 'function') _applyTabVisibility(hidden); _ensureComposerControlVisibilityState(s||{}); if(Array.isArray(s&&s.composer_control_order)){ const nextOrder=_setComposerControlOrder(s.composer_control_order); if(typeof window._applyComposerControlOrder==='function') window._applyComposerControlOrder(nextOrder); } _renderComposerControlChips(); _renderComposerSituationalControlChips(); if(typeof _applyComposerFooterVisibilitySettings==='function') _applyComposerFooterVisibilitySettings(); window._showTitlebarProfile=!!(s&&s.show_titlebar_profile); if(typeof _applyTitlebarProfileVisibility==='function') _applyTitlebarProfileVisibility(); }).catch(function(){}); } async function loadProfilesPanel() { const panel = $('profilesPanel'); if (!panel) return; try { const data = await api('/api/profiles'); _profilesCache = data; _profileDropdownWriteStoredCache(data); panel.innerHTML = ''; // Hide "New profile" button in single profile mode const newProfileBtn = document.querySelector('[onclick="openProfileCreate()"]'); if (newProfileBtn) { newProfileBtn.style.display = data.single_profile_mode ? 'none' : ''; } // In single profile mode, don't show the explanatory card if (!data.single_profile_mode) { const explainer = document.createElement('div'); explainer.className = 'profile-card profile-help-card'; explainer.innerHTML = `
    ${esc(t('profile_concept_title'))}
    ${esc(t('profile_concept_subtitle'))}
    `; explainer.onclick = () => _renderProfileConceptHelp(data.active || 'default'); panel.appendChild(explainer); } if (!data.profiles || !data.profiles.length) { const emptyMsg = document.createElement('div'); emptyMsg.style.cssText = 'padding:16px;color:var(--muted);font-size:12px'; emptyMsg.textContent = t('profiles_no_profiles'); panel.appendChild(emptyMsg); 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 (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop()); if (p.provider) meta.push(p.provider); if (p.total_skills && p.total_skills > 0) meta.push(t('profile_skill_count', p.total_skills).replace(String(p.total_skills), `${p.enabled_skills} / ${p.total_skills}`)); 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'))}` : ''; const hiddenBadge = p.visible === false ? ' Hidden from chat' : ''; card.innerHTML = `
    ${gwDot}${esc(p.name)}${defaultBadge}${activeBadge}${hiddenBadge}
    ${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 _renderProfileConceptHelp(activeName){ const title = $('profileDetailTitle'); const body = $('profileDetailBody'); const empty = $('profileDetailEmpty'); if (!title || !body) return; title.textContent = t('profile_concept_title'); body.innerHTML = `
    ${esc(t('profile_concept_title'))}
    ${esc(t('tab_profiles'))}
    ${esc(t('profile_concept_desc_profiles'))}
    ${esc(t('tab_workspaces'))}
    ${esc(t('profile_concept_desc_workspaces'))}
    ${esc(t('profile_concept_label_together'))}
    ${esc(t('profile_concept_desc_together'))}
    ${esc(t('profile_concept_label_example'))}
    ${esc(t('profile_concept_example'))}
    `; body.style.display = ''; if (empty) empty.style.display = 'none'; _profileMode = 'read'; _currentProfileDetail = null; _setProfileHeaderButtons('help'); } 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 (p.total_skills && p.total_skills > 0) rows.push(`
    Skills
    ${esc(t('profile_skill_count', p.total_skills).replace(String(p.total_skills), `${p.enabled_skills} / ${p.total_skills}`))}
    `); 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 header = $('mainProfiles') && $('mainProfiles').querySelector('.main-view-header'); 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') { if (header) header.style.display = 'flex'; const isActive = p && p.name === activeName; const isDefault = !!(p && p.is_default); const singleProfileMode = !!(_profilesCache && _profilesCache.single_profile_mode); if (isActive || singleProfileMode) hide(actBtn); else show(actBtn); if (isDefault || singleProfileMode) hide(delBtn); else show(delBtn); hide(cancelBtn); hide(saveBtn); } else if (mode === 'create') { if (header) header.style.display = 'flex'; hide(actBtn); hide(delBtn); show(cancelBtn); show(saveBtn); } else if (mode === 'help') { // Read-only help/concept view: title is populated, so show the header but // hide every action button (no profile to act on). if (header) header.style.display = 'flex'; [actBtn, delBtn, cancelBtn, saveBtn].forEach(hide); } else { if (header) header.style.display = 'none'; [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); _closeMobileSidebarAfterPanelSelection(); } 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 }) }); _invalidateKanbanProfileCache(); _clearProfileDetail(); await loadProfilesPanel(); showToast(t('profile_deleted', name)); } catch (e) { showToast(t('delete_failed') + e.message); } } function renderProfileDropdown(data) { data = data || {}; const dd = $('profileDropdown'); if (!dd) return; dd.innerHTML = ''; const allProfiles = (Array.isArray(data.profiles) ? data.profiles : []).filter(p => p && typeof p.name === 'string'); const active = (S.activeProfile && allProfiles.some(p => p.name === S.activeProfile)) ? S.activeProfile : (data.active || 'default'); const profiles = allProfiles.filter(p => p && (p.visible !== false || p.name === active)); for (const p of profiles) { const opt = document.createElement('div'); opt.className = 'profile-opt' + (p.name === active ? ' active' : ''); const meta = []; if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop()); if (p.total_skills && p.total_skills > 0) meta.push(t('profile_skill_count', p.total_skills).replace(String(p.total_skills), `${p.enabled_skills} / ${p.total_skills}`)); 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 (hidden in single profile mode) if (!data.single_profile_mode) { 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); } // Sync titlebar label to the resolved active profile const tbl = $('titlebarProfileLabel'); if (tbl) tbl.textContent = active; } function toggleProfileDropdown(e) { const dd = $('profileDropdown'); if (!dd) return; if (dd.classList.contains('open')) { closeProfileDropdown(); return; } closeWsDropdown(); // close workspace dropdown if open if(typeof closeModelDropdown==='function') closeModelDropdown(); // Track which element triggered the dropdown for positioning _profileDropdownTrigger = (e && e.currentTarget) || $('profileChip'); const openGen = ++_profileDropdownOpenGeneration; const cached = _profileDropdownBestCachedData(); if(cached && !cached.single_profile_mode){ renderProfileDropdown(cached); _openProfileDropdownShell(); }else{ _renderProfileDropdownLoading(); _openProfileDropdownShell(); } _profileDropdownFetchFresh().then(data => { if(openGen !== _profileDropdownOpenGeneration) return; // In single profile mode, don't show profile dropdown at all if (data.single_profile_mode) { closeProfileDropdown(); return; } renderProfileDropdown(data); _openProfileDropdownShell(); }).catch(e => { if(openGen !== _profileDropdownOpenGeneration) return; if(cached && !cached.single_profile_mode){ // Keep the cached menu open; the next click/background refresh will retry. return; } closeProfileDropdown(); showToast(t('profiles_load_failed')); }); } function closeProfileDropdown() { _profileDropdownOpenGeneration++; const dd = $('profileDropdown'); if (dd) dd.classList.remove('open'); const chip=$('profileChip'); if(chip) chip.classList.remove('active'); const tbtn=$('titlebarProfileBtn'); if(tbtn) tbtn.classList.remove('active'); } document.addEventListener('click', e => { if (!e.target.closest('#profileChipWrap') && !e.target.closest('#titlebarProfileBtn') && !e.target.closest('#profileDropdown')) closeProfileDropdown(); }); window.addEventListener('resize',()=>{ const dd=$('profileDropdown'); if(dd&&dd.classList.contains('open')) _positionProfileDropdown(); }); function _openProfileSwitchSessionBrowser(){ try{ const isDesktop = (typeof _isDesktopWidth === 'function') ? _isDesktopWidth() : true; if(isDesktop){ if(typeof expandSidebar === 'function') expandSidebar(); return; } const sidebar=document.querySelector('.sidebar'); if(!sidebar)return; try{if(typeof _syncMobileSidebarPanelFromMainView==='function')_syncMobileSidebarPanelFromMainView();}catch(_){} sidebar.classList.remove('mobile-session-page'); sidebar.classList.add('mobile-panel-drawer','mobile-open'); }catch(_){} } async function switchToProfile(name) { // ── #4671 profile-switch loading-skeleton — FOUR-GUARD CONTRACT ─────────────── // The skeleton must never be clobbered by the OLD profile's content and must never // strand. Four interacting pieces of state cooperate; an edit touching one without // the others can silently reopen a clobber/strand window, so keep them in sync: // 1. _profileSwitchListEmbargo (sessions.js) — set BEFORE the skeleton, drops EVERY // session-list payload (success + fetch-failure) during the switch window; lifted // immediately before the switch-owned renderSessionList(), on failure-restore, and // in the _switchGen-guarded finally. Closes the "render that STARTS mid-switch, // before the new-profile cookie is set, fetched the old profile" window. // 2. _invalidateSessionListRenders() (sessions.js) — bumps _renderSessionListGen + // clears pending/queued at switch start; discards renders already in flight/queued. // 3. _sessionListSkeletonActive (sessions.js) — renderSessionListFromCache() bails // while true; cleared ONLY on fresh data (_applySessionListPayload), fetch-error, // and failure-restore — so a bail can't strand the skeleton. // 4. _wsTreeGen (workspace.js) — bumped UNCONDITIONALLY here (incl. panel-closed, since // loadDir('.') still runs); loadDir rejects stale /api/list whose gen is superseded. // Plus _profileSwitchGeneration / _switchGen — guards superseded switches so a slower // earlier switch can't clobber a newer one's skeleton/embargo. // ────────────────────────────────────────────────────────────────────────────── // No-op self-switch guard: bail before showing any loading skeleton if we're // already on this profile, so paths like activateCurrentProfile() (which // doesn't pre-check) can't flash a skeleton→restore for a click that changes // nothing. (#4662 Opus gate) if (name && name === S.activeProfile) return true; S._pendingSessionToolsets=null; // Profile switches are per-client cookie/TLS scoped, so a running stream in // the current session can safely continue while this tab moves to another // profile. The in-flight session stays attached to its original profile. // ── 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 _titlebarBtn = $('titlebarProfileBtn'); const _titlebarLabel = $('titlebarProfileLabel'); const _prevProfileName = S.activeProfile || 'default'; const _switchGen = ++_profileSwitchGeneration; const _openingExistingSidebarSession = !!(typeof _profileSwitchOpeningExistingSession !== 'undefined' && _profileSwitchOpeningExistingSession); if (_chip) { _chip.classList.add('switching'); _chip.disabled = true; } if (_titlebarBtn) { _titlebarBtn.classList.add('switching'); _titlebarBtn.disabled = true; } // Optimistic name update — shows the target name right away if (_chipLabel) _chipLabel.textContent = name; if (_titlebarLabel) _titlebarLabel.textContent = name; // ── Clear stale content + show loading skeletons immediately (#4662) ─────── // The conversation list and workspace tree still show the PREVIOUS profile's // content until their fetches resolve (~1s). Replace them with skeletons the // instant the switch begins so the user never stares at the wrong profile's // data, and gets consistent loading feedback across the whole surface — not // just the spinning chip. The real renders below overwrite these. // // First dismiss any open inline-rename or row action menu: renderSessionList // FromCache() early-returns (no DOM swap) while _renamingSid or // _sessionActionMenu is set, which would otherwise strand the skeleton AND // defeat the failure-path restore (#4662 Opus gate). A profile switch is a // context change where dismissing those transient affordances is correct. if (typeof _renamingSid !== 'undefined' && _renamingSid) _renamingSid = null; if (typeof closeSessionActionMenu === 'function') closeSessionActionMenu(); // Determine whether the current session must be replaced instead of being // retagged in place. A session with messages/active runtime belongs to the // current profile. After the profile-switch POST returns, we also treat an // otherwise-empty session whose recorded profile does not match the target // profile as replace-only: uploads send S.session.session_id and the backend // correctly rejects old-profile sessions under the new profile cookie. let sessionInProgress = !!(S.session && ( (S.messages && S.messages.length > 0) || S.session.active_stream_id || S.session.pending_user_message )); if (_openingExistingSidebarSession && S.session) { // A cross-profile sidebar click is about to load a concrete existing session. // Do not create or retag a blank intermediary session in the destination profile. sessionInProgress = true; } const _workspaceVisibleAtStart = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed'; // #4671 CORE: the skeleton/embargo/generation setup is INSIDE the try so the // _switchGen-guarded finally always lifts the embargo — a throw in this synchronous // setup can't leak the embargo and freeze the sidebar (Codex re-gate 4). try { // Invalidate any in-flight/queued session-list render BEFORE showing the skeleton, // so a pre-switch /api/sessions response (old profile's rows, issued before the // switch) can't resolve, pass the generation guard, clear the skeleton flag, and // paint stale rows. Must precede showSessionListSkeleton(). if (typeof _invalidateSessionListRenders === 'function') _invalidateSessionListRenders(); // ...and set the embargo so a render that STARTS during the switch window (after the // skeleton, before the new-profile cookie is set) also can't paint the old profile's // rows. Cleared right before the switch-owned renderSessionList() and on failure. if (typeof _setProfileSwitchListEmbargo === 'function') _setProfileSwitchListEmbargo(true); if (typeof showSessionListSkeleton === 'function') showSessionListSkeleton(name); // invalidate any in-flight workspace-tree load UNCONDITIONALLY at switch start — even // when the panel is closed, loadDir('.') still runs later, and an empty-session switch // reuses the same session_id so loadDir's id guard alone can't reject a stale // previous-workspace /api/list. Bump here (not only inside the panel-gated // showWorkspaceTreeSkeleton) to close the closed-panel race. if (typeof bumpWorkspaceTreeGen === 'function') bumpWorkspaceTreeGen(); if (_workspaceVisibleAtStart && typeof showWorkspaceTreeSkeleton === 'function') showWorkspaceTreeSkeleton(); // timeoutToast:false — suppress api()'s generic "Request timed out" toast so a // superseded or transient-but-eventually-successful switch can't pop a spurious // red error while the real switch completes and renders. The catch block below is // the single source of truth for switch failure and is gated on _switchGen, so the // error surfaces ONLY when the CURRENT switch genuinely fails (@rodboev review, #4662). const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }), timeoutToast: false }); if (_switchGen !== _profileSwitchGeneration) return false; S.activeProfile = data.active || name; S.activeProfileIsDefault = !!data.is_default; if (typeof _resetCronUnreadForProfileSwitch === 'function') { _resetCronUnreadForProfileSwitch(); } // #7509: the slash-skill caches hold the previous profile's disabled-filtered // /api/skills payload, so drop them once the switch has actually succeeded — // otherwise a skill that is enabled in the new profile stays hidden behind the // old payload. Invalidating here (rather than before the POST) also kills any // response still in flight, so the previous profile's reply can't repopulate // the caches after this point (see invalidateSlashSkillCaches in commands.js). if (typeof window !== 'undefined' && typeof window.invalidateSlashSkillCaches === 'function') { window.invalidateSlashSkillCaches(); } const targetActiveProfile = S.activeProfile || 'default'; let sessionProfileMatchesTarget = true; if (!sessionInProgress && S.session) { const currentSessionProfile = (typeof S.session.profile === 'string' && S.session.profile.trim()) ? S.session.profile.trim() : 'default'; sessionProfileMatchesTarget = (typeof _profileMatchesActiveProfile === 'function') ? _profileMatchesActiveProfile(currentSessionProfile, targetActiveProfile) : (currentSessionProfile === targetActiveProfile || (currentSessionProfile === 'default' && !!S.activeProfileIsDefault)); if (!sessionProfileMatchesTarget) { sessionInProgress = true; } } // Reconnect the gateway SSE to the NEW profile's watcher. The backend watcher // registry is now profile-keyed (#3629), but this tab's existing EventSource is // still subscribed to the PREVIOUS profile's watcher — and the probe-based // reattach is gated on `!_gatewaySSE`, which can't fire while the old stream is // open. startGatewaySSE() closes the old ES (stopGatewaySSE) and reconnects with // the new profile cookie; it self-gates on window._showCliSessions internally. if (typeof startGatewaySSE === 'function') startGatewaySSE(); // Update composer placeholder and title bar while the core profile-switch // state is still close to the profile API response. if (typeof applyBotName === 'function') applyBotName(); // ── Model + Workspace ────────────────────────────────────────────────── // Apply the profile defaults returned by /api/profile/switch immediately. // Refreshing the full model/workspace catalogs is useful, but it should not // hold the visible switch animation open. if(typeof _clearPersistedModelState==='function') _clearPersistedModelState(); else localStorage.removeItem('hermes-webui-model'); _skillsData = null; _workspaceList = null; if (data.default_model) window._defaultModel = data.default_model; if (data.default_model_provider) window._activeProvider = data.default_model_provider; // ── Apply model ──────────────────────────────────────────────────────── if (data.default_model) { const sel = $('modelSelect'); const providerId = data.default_model_provider || window._activeProvider || null; const existingDefaultOpt = sel ? Array.from(sel.options).find(o => o.value === data.default_model) : null; if (existingDefaultOpt && providerId && !existingDefaultOpt.dataset.provider) { existingDefaultOpt.dataset.provider = providerId; } if (sel && !existingDefaultOpt) { const opt = document.createElement('option'); opt.value = data.default_model; opt.textContent = typeof getModelLabel === 'function' ? getModelLabel(data.default_model) : data.default_model; opt.dataset.custom = '1'; if (providerId) opt.dataset.provider = providerId; sel.querySelectorAll('option[data-custom]').forEach(o => o.remove()); sel.appendChild(opt); } const resolved = _applyModelToDropdown(data.default_model, sel, providerId); const modelToUse = resolved || data.default_model; const modelState = (typeof _modelStateForSelect==='function') ? _modelStateForSelect(sel, modelToUse) : {model:modelToUse,model_provider:providerId}; S._pendingProfileModel = modelToUse; S._pendingProfileModelProvider = modelState.model_provider||providerId||null; // Only patch the in-memory session model if we're NOT about to replace the session if (S.session && !sessionInProgress) { S.session.model = modelToUse; S.session.model_provider = modelState.model_provider||providerId||null; S.session.profile = data.active || name; } } // #3331 follow-up (Codex gate): retag the in-memory session's profile on // ANY profile switch, even when the switched-to profile returns no // default_model (empty session / model-less profile). Without this the // profile chip + project-picker filter keep the stale profile after a // switch to a model-less profile. Guarded by !sessionInProgress like the // model patch above (don't touch a session about to be replaced). if (S.session && !sessionInProgress) { S.session.profile = data.active || name; } if (typeof refreshProfileTransitionReasoningChip === 'function') { refreshProfileTransitionReasoningChip(data.default_model, data.default_model_provider); } // ── 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, model_provider: S.session.model_provider||null, })}); S.session.workspace = data.default_workspace; } catch (_) {} } } // ── Session ──────────────────────────────────────────────────────────── // Keep the all-profiles sidebar scope sticky across profile switches. It is // a navigation preference shared by the browser session, not a per-profile flag. if (typeof animateNextSessionListRefresh === 'function') animateNextSessionListRefresh(); if (sessionInProgress && _openingExistingSidebarSession) { // The caller will immediately load the clicked session after this profile // cookie switch. Avoid creating/retagging an intermediate blank chat. const workspaceVisible = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed'; if (typeof _setProfileSwitchListEmbargo === 'function') _setProfileSwitchListEmbargo(false); await renderSessionList(); if (_switchGen !== _profileSwitchGeneration) return false; if (workspaceVisible && typeof clearWorkspaceTreeSkeleton === 'function') clearWorkspaceTreeSkeleton(); showToast(t('profile_switched', name)); } else 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. const workspaceVisible = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed'; await newSession(false, {awaitWorkspaceLoad: workspaceVisible, worktree: false}); if (_switchGen !== _profileSwitchGeneration) return false; // Keep topbar chips (workspace/profile) in sync after creating the // new profile-scoped session. syncTopbar(); // #4671: lift the embargo immediately before the switch-owned render — JS is // single-threaded so nothing interleaves between this clear and the call, making // this render the first allowed to paint the new profile's rows. if (typeof _setProfileSwitchListEmbargo === 'function') _setProfileSwitchListEmbargo(false); await renderSessionList(); // Re-check generation after the awaited list render: a newer switch can be // started while renderSessionList() is in flight, and without this guard // the superseded switch would clear the newer switch's workspace skeleton // and pop a stale toast. Mirrors the no-messages branch guard below. // (@rodboev/greptile review, #4662) if (_switchGen !== _profileSwitchGeneration) return false; if (typeof _openProfileSwitchSessionBrowser === 'function') _openProfileSwitchSessionBrowser(); // Safety net: if the new session has no workspace, newSession() won't have // painted the file tree — clear the up-front skeleton so it can't strand // (#4662 Opus gate). No-op when a real tree already rendered. if ((!S.session || !S.session.workspace) && typeof clearWorkspaceTreeSkeleton === 'function') { clearWorkspaceTreeSkeleton(); } showToast(t('profile_switched_new_conversation', name)); } else { // No messages yet — refresh the list and topbar in place, then the // workspace tree. The loading skeletons shown up front (top of this // function) already give immediate cross-surface feedback, so we keep the // workspace refresh AFTER the stale-switch guard: loadDir() paints the // file tree as soon as its fetch resolves with only a session-id check, // and empty-session switches reuse the same session id — so starting it // before the guard could let an older switch's /api/list paint over a // newer one (Codex gate #4662). renderSessionList() is the slow fetch and // has its own internal generation guard, so awaiting it first is fine. const workspaceVisible = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed'; // #4671: lift the embargo immediately before the switch-owned render (see above). if (typeof _setProfileSwitchListEmbargo === 'function') _setProfileSwitchListEmbargo(false); await renderSessionList(); if (_switchGen !== _profileSwitchGeneration) return; if (typeof _openProfileSwitchSessionBrowser === 'function') _openProfileSwitchSessionBrowser(); 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) { const dirLoad = loadDir('.'); if (workspaceVisible) await dirLoad; } else if (typeof clearWorkspaceTreeSkeleton === 'function') { // New profile has no bound workspace — clear the up-front skeleton so it // doesn't strand (#4662 Opus gate). clearWorkspaceTreeSkeleton(); } showToast(t('profile_switched', name)); } await _profileSwitchPanelLoad(); _refreshProfileSwitchBackground(_switchGen); return true; } catch (e) { // Revert the optimistic name update on error if (_switchGen === _profileSwitchGeneration && _chipLabel) _chipLabel.textContent = _prevProfileName; if (_switchGen === _profileSwitchGeneration && _titlebarLabel) _titlebarLabel.textContent = _prevProfileName; if (_switchGen === _profileSwitchGeneration) showToast(t('switch_failed') + e.message); // The switch failed, so we're still on the previous profile and its caches // are intact — restore the real list/tree so the loading skeletons we showed // up front don't strand. (#4662) if (_switchGen === _profileSwitchGeneration) { // The switch failed; _allSessions still holds the (still-current) previous // profile, so clear the skeleton flag and re-render to restore the real list // rather than strand the up-front skeleton (#4671). Lift the embargo too so the // restore render (and subsequent normal renders) can paint. if (typeof _setProfileSwitchListEmbargo === 'function') _setProfileSwitchListEmbargo(false); _sessionListSkeletonActive = false; if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache(); if (_workspaceVisibleAtStart && S.session && S.session.workspace && typeof loadDir === 'function') { loadDir('.'); } else if (_workspaceVisibleAtStart && typeof clearWorkspaceTreeSkeleton === 'function') { // No workspace to restore on the (still-current) previous profile — // clear the up-front workspace skeleton so it doesn't strand on a switch // failure, mirroring the success-path no-workspace handling (#4662). clearWorkspaceTreeSkeleton(); } } return false; } finally { // Always remove loading indicator regardless of success or failure if (_switchGen === _profileSwitchGeneration && _chip) { _chip.classList.remove('switching'); _chip.disabled = false; } if (_switchGen === _profileSwitchGeneration && _titlebarBtn) { _titlebarBtn.classList.remove('switching'); _titlebarBtn.disabled = false; } // #4671 safety net: guarantee the session-list embargo is lifted on EVERY exit of the // current switch (success paths clear it before their authoritative render; this covers // early-returns/throws between skeleton-show and those clears so it can't freeze the // sidebar). Guarded by _switchGen so a superseded switch can't lift a newer switch's embargo. if (_switchGen === _profileSwitchGeneration && typeof _setProfileSwitchListEmbargo === 'function') { _setProfileSwitchListEmbargo(false); } } } function openProfileCreate(){ if (typeof switchPanel === 'function' && _currentPanel !== 'profiles') switchPanel('profiles'); _profilePreFormDetail = _currentProfileDetail ? { ..._currentProfileDetail } : null; _profileMode = 'create'; _renderProfileForm(); // Mobile: the new-profile form lives in the main view, which is covered by the // full-screen sidebar drawer. Close the drawer so the form is visible (mirror // openWorkspaceDetail's behaviour); no-op on desktop. _closeMobileSidebarAfterPanelSelection(); } 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.')}
    ${esc(t('profile_model_hint') || 'Choose from configured providers and models for this new profile.')}
    `; body.style.display = ''; if (empty) empty.style.display = 'none'; _setProfileHeaderButtons('create'); const n = $('profileFormName'); if (n) n.focus(); _populateProfileFormModelSelect(); } async function _populateProfileFormModelSelect(){ const sel = $('profileFormModel'); if (!sel) return; sel.innerHTML = ``; try { const data = await api('/api/models'); const groups = (Array.isArray(data && data.groups) && data.groups.length) ? data.groups : []; for (const g of groups) { const og = document.createElement('optgroup'); og.label = g.provider || g.provider_id || 'Configured'; if (g.provider_id) og.dataset.provider = g.provider_id; for (const m of [...(Array.isArray(g.models) ? g.models : []), ...(Array.isArray(g.extra_models) ? g.extra_models : [])]) { if (!m || !m.id) continue; const opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.label || m.id; og.appendChild(opt); } if (og.children.length) sel.appendChild(og); } if (data && data.default_model && typeof _applyModelToDropdown === 'function') { _applyModelToDropdown(data.default_model, sel, data.active_provider || window._activeProvider || null); } } catch (e) { console.warn('Failed to load profile model picker:', e.message); } } 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 modelEl = $('profileFormModel'); 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 }; const selectedModel = modelEl ? (modelEl.value || '').trim() : ''; if (selectedModel) { const modelState = (typeof _modelStateForSelect === 'function') ? _modelStateForSelect(modelEl, selectedModel) : { model: selectedModel, model_provider: null }; if (modelState.model) payload.default_model = modelState.model; if (modelState.model_provider) payload.model_provider = modelState.model_provider; } if (baseUrl) payload.base_url = baseUrl; if (apiKey) payload.api_key = apiKey; await api('/api/profile/create', { method: 'POST', body: JSON.stringify(payload) }); _invalidateKanbanProfileCache(); _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 }) }); _invalidateKanbanProfileCache(); 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 memoryUrl = S.session && S.session.session_id ? `/api/memory?session_id=${encodeURIComponent(S.session.session_id)}` : '/api/memory'; const data = await api(memoryUrl); _memoryData = data; if (_currentMemorySection === 'external_notes' && !data.external_notes_enabled) { _currentMemorySection = null; } if (_currentMemorySection === 'external_notes') { await loadNotesSources(!!force); } if (panel) { panel.innerHTML = ''; for (const s of MEMORY_SECTIONS) { if (s.key === 'external_notes' && !_memoryData.external_notes_enabled) continue; 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(_memorySectionLabel(s))}`; const sectionPath = _memorySectionPath(s.key); if (sectionPath) el.title = sectionPath; 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(); const isWsPath=e.dataTransfer.types.includes('application/ws-path'); const isFiles=e.dataTransfer.types.includes('Files'); if(isFiles||isWsPath){ dragCounter++; // Context-aware hint: a workspace-file drag inserts an @path reference; // an OS-file drag attaches the file to the message. const hint=$('dropHintText'); if(hint) hint.textContent=isWsPath?'Drop to insert workspace reference':'Drop files to attach'; 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 _settingsHermesDefaultModelProviderOnOpen = null; let _settingsSection = 'conversation'; let _currentSettingsSection = 'conversation'; let _settingsIndex = null; let _settingsIndexPromise = null; let _settingsSearchSeq = 0; let _extensionsStatusData = null; let _extensionsSidecarMonitorSeq = 0; let _extensionsGalleryData = null; let _extensionsGalleryLoaded = false; let _extensionsActiveTab = 'gallery'; let _settingsSearchDismissListenerRegistered = false; let _settingsAppearanceAutosaveTimer = null; let _settingsAppearanceAutosaveRetryPayload = null; let _settingsPreferencesAutosaveTimer = null; let _settingsPreferencesAutosaveRetryPayload = null; // ── Sidebar tab visibility/order ──────────────────────────────────────────── const _ALWAYS_VISIBLE_TABS = new Set(['chat','settings']); const _HIDDEN_TABS_LS_KEY = 'hermes-webui-hidden-tabs'; const _TAB_ORDER_LS_KEY = 'hermes-webui-tab-order'; const _COMPOSER_CONTROL_ORDER_LS_KEY = 'hermes-webui-composer-control-order'; let _tabVisibilityDragSuppressUntil = 0; let _composerControlDragSuppressUntil = 0; let _composerControlDraggingKey = ''; function _sanitizeTabPanelList(panels){ if(!Array.isArray(panels)) return []; var out=[]; panels.forEach(function(panel){ if(typeof panel!=='string') return; panel=panel.trim(); if(!panel||_ALWAYS_VISIBLE_TABS.has(panel)) return; if(out.indexOf(panel)===-1) out.push(panel); }); return out; } function _getHiddenTabs(){ try{var h=localStorage.getItem(_HIDDEN_TABS_LS_KEY);if(h)return _sanitizeTabPanelList(JSON.parse(h));}catch(e){} return[]; } function _setHiddenTabs(panels){ try{localStorage.setItem(_HIDDEN_TABS_LS_KEY,JSON.stringify(_sanitizeTabPanelList(panels)));}catch(e){} } function _getTabOrder(){ try{var h=localStorage.getItem(_TAB_ORDER_LS_KEY);if(h)return _sanitizeTabPanelList(JSON.parse(h));}catch(e){} return[]; } function _setTabOrder(panels){ try{localStorage.setItem(_TAB_ORDER_LS_KEY,JSON.stringify(_sanitizeTabPanelList(panels)));}catch(e){} } function _availableSidebarPanels(){ var out=[]; var tabs=document.querySelectorAll('.rail .rail-btn.nav-tab[data-panel], .sidebar-nav .nav-tab[data-panel]'); tabs.forEach(function(tab){ var panel=tab.dataset.panel; if(!panel||_ALWAYS_VISIBLE_TABS.has(panel)) return; if(tab.classList.contains('dashboard-link')||tab.hasAttribute('data-dashboard-link')) return; if(out.indexOf(panel)===-1) out.push(panel); }); return out; } function _orderedSidebarPanels(order){ var available=_availableSidebarPanels(); var requested=_sanitizeTabPanelList(Array.isArray(order)?order:_getTabOrder()); var out=[]; requested.forEach(function(panel){ if(available.indexOf(panel)!==-1&&out.indexOf(panel)===-1) out.push(panel); }); available.forEach(function(panel){ if(out.indexOf(panel)===-1) out.push(panel); }); return out; } function _dashboardPanelMode(){ var modeEl=$('settingsDashboardMode'); var mode=modeEl&&modeEl.value; return mode==='never'||mode==='always'||mode==='auto'?mode:'auto'; } function _isDashboardChipOn(){ return _dashboardPanelMode()!=='never'; } function _renderDashboardVisibilityChip(container){ if(!container)return null; var chip=document.createElement('button'); chip.type='button'; chip.className='tab-visibility-chip'; chip.setAttribute('data-tab-panel','__hermes_dashboard__'); chip.setAttribute('role','switch'); var isOn=_isDashboardChipOn(); chip.setAttribute('aria-checked',isOn?'true':'false'); if(!isOn) chip.classList.add('chip-off'); chip.textContent=typeof t==='function'?t('tab_dashboard'):'Dashboard'; chip.onclick=function(){ if(Date.now()<_tabVisibilityDragSuppressUntil)return; _toggleDashboardVisibilityChip(); }; return chip; } function _applyTabOrder(order){ var ordered=_orderedSidebarPanels(order); ['.rail','.sidebar-nav'].forEach(function(selector){ var container=document.querySelector(selector); if(!container) return; var anchor=Array.prototype.find.call(container.children,function(child){ if(child.classList&&child.classList.contains('rail-spacer')) return true; if(child.classList&&child.classList.contains('dashboard-link')) return true; if(child.hasAttribute&&child.hasAttribute('data-dashboard-link')) return true; return child.dataset&&child.dataset.panel==='settings'; }); ordered.forEach(function(panel){ var node=container.querySelector('.nav-tab[data-panel="'+panel+'"]'); if(node) container.insertBefore(node,anchor||null); }); }); } function _applyTabVisibility(hidden){ hidden=_sanitizeTabPanelList(hidden); _applyTabOrder(_getTabOrder()); // Hide/unhide all [data-panel] elements (sidebar-nav buttons + rail buttons) document.querySelectorAll('[data-panel]').forEach(function(el){ var panel=el.dataset.panel; if(!panel)return; var shouldHide=hidden.indexOf(panel)!==-1; // Never hide always-visible panels (chat, settings) even if present in hidden_tabs if(_ALWAYS_VISIBLE_TABS.has(panel)) shouldHide=false; el.classList.toggle('nav-tab-hidden',shouldHide); }); // If the currently active tab is hidden, switch to chat var activeRail=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]'); var activeSidebar=document.querySelector('.sidebar-nav .nav-tab.active[data-panel]'); var activeEl=activeRail||activeSidebar; if(activeEl&&activeEl.classList.contains('nav-tab-hidden')){ if(typeof switchPanel==='function') switchPanel('chat'); } } function _renderTabVisibilityChips(){ var container=$('tabVisibilityChips'); if(!container)return; var hidden=_getHiddenTabs(); var panels=_orderedSidebarPanels(); container.innerHTML=''; panels.forEach(function(panel){ var tab=document.querySelector('.rail .rail-btn.nav-tab[data-panel="'+panel+'"]') ||document.querySelector('.sidebar-nav .nav-tab[data-panel="'+panel+'"]'); var label=(tab&&(tab.dataset.tooltip||tab.dataset.label))||panel; // Capitalize first letter label=label.charAt(0).toUpperCase()+label.slice(1); var chip=document.createElement('button'); chip.type='button'; chip.className='tab-visibility-chip'; var isOff=hidden.indexOf(panel)!==-1; if(isOff)chip.classList.add('chip-off'); chip.textContent=label; chip.setAttribute('data-tab-panel',panel); chip.setAttribute('draggable','true'); // Use role="switch" + aria-checked instead of aria-pressed so screen // readers narrate "Tasks switch on/off" (matches user mental model) rather // than "Tasks toggle button pressed/not-pressed" (where the polarity is // confusing because chip-off looks like the "off" state). chip.setAttribute('role','switch'); chip.setAttribute('aria-checked',isOff?'false':'true'); chip.onclick=function(){ if(Date.now()<_tabVisibilityDragSuppressUntil)return; _toggleTabVisibilityChip(panel); }; _wireTabChipDrag(chip,panel); container.appendChild(chip); }); var dashboardChip=_renderDashboardVisibilityChip(container); if(dashboardChip) container.appendChild(dashboardChip); } function _wireTabChipDrag(chip,panel){ if(!chip)return; chip.addEventListener('dragstart',function(e){ chip.classList.add('dragging'); if(e.dataTransfer){ e.dataTransfer.effectAllowed='move'; e.dataTransfer.setData('text/plain',panel); } }); chip.addEventListener('dragend',function(){chip.classList.remove('dragging');}); chip.addEventListener('dragover',function(e){e.preventDefault();chip.classList.add('drag-over');if(e.dataTransfer)e.dataTransfer.dropEffect='move';}); chip.addEventListener('dragleave',function(){chip.classList.remove('drag-over');}); chip.addEventListener('drop',function(e){_handleTabVisibilityChipDrop(e,panel);}); } function _moveTabOrderPanel(sourcePanel,targetPanel){ if(!sourcePanel||!targetPanel||sourcePanel===targetPanel) return false; var order=_orderedSidebarPanels(); var from=order.indexOf(sourcePanel); var to=order.indexOf(targetPanel); if(from===-1||to===-1) return false; order.splice(from,1); order.splice(to,0,sourcePanel); _setTabOrder(order); _applyTabOrder(order); _renderTabVisibilityChips(); _scheduleAppearanceAutosave(); return true; } function _handleTabVisibilityChipDrop(e,targetPanel){ if(e){e.preventDefault();e.stopPropagation();} document.querySelectorAll('.tab-visibility-chip.drag-over').forEach(function(el){el.classList.remove('drag-over');}); var sourcePanel=e&&e.dataTransfer?e.dataTransfer.getData('text/plain'):''; if(_moveTabOrderPanel(sourcePanel,targetPanel)) _tabVisibilityDragSuppressUntil=Date.now()+250; } function _toggleTabVisibilityChip(panel){ if(_ALWAYS_VISIBLE_TABS.has(panel))return; var hidden=_getHiddenTabs(); var idx=hidden.indexOf(panel); if(idx!==-1){ hidden.splice(idx,1); }else{ hidden.push(panel); } _setHiddenTabs(hidden); _applyTabVisibility(hidden); _renderTabVisibilityChips(); _scheduleAppearanceAutosave(); } function _toggleDashboardVisibilityChip(){ var modeEl=$('settingsDashboardMode'); if(!modeEl||typeof saveDashboardSettings!=='function') return; var currentMode=_dashboardPanelMode(); var nextMode=currentMode==='never' ? (typeof _getDashboardChipRestoreMode==='function' ? _getDashboardChipRestoreMode() : 'auto') : 'never'; var previousMode=currentMode; modeEl.value=nextMode; Promise.resolve(saveDashboardSettings({raiseOnError:true})).catch(function(){ modeEl.value=previousMode; if(typeof _renderTabVisibilityChips==='function') _renderTabVisibilityChips(); }); } function _ensureComposerControlVisibilityState(settings){ const fromSettings=(typeof _composerControlVisibilityFromSettings==='function') ? _composerControlVisibilityFromSettings(settings||{}) : {}; if(!window._composerControlVisibility) window._composerControlVisibility={}; Object.assign(window._composerControlVisibility, fromSettings); } function _composerControlDefsForSettings(){ const baseDefs=Array.isArray(window._COMPOSER_CONTROL_TOGGLE_DEFS)?window._COMPOSER_CONTROL_TOGGLE_DEFS:[]; const situationalDefs=Array.isArray(window._COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS)?window._COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS:[]; return baseDefs.concat(situationalDefs); } function _getComposerControlOrder(){ if(Array.isArray(window._composerControlOrder)){ return typeof window._sanitizeComposerControlOrder==='function' ? window._sanitizeComposerControlOrder(window._composerControlOrder) : window._composerControlOrder.slice(); } try{ const raw=localStorage.getItem(_COMPOSER_CONTROL_ORDER_LS_KEY); if(raw){ const parsed=JSON.parse(raw); if(typeof window._sanitizeComposerControlOrder==='function') return window._sanitizeComposerControlOrder(parsed); if(Array.isArray(parsed)) return parsed.filter(key=>typeof key==='string'); } }catch(e){} return []; } function _setComposerControlOrder(order){ const sanitized=typeof window._sanitizeComposerControlOrder==='function' ? window._sanitizeComposerControlOrder(order) : (Array.isArray(order)?order.filter(key=>typeof key==='string') : []); window._composerControlOrder=sanitized; try{localStorage.setItem(_COMPOSER_CONTROL_ORDER_LS_KEY,JSON.stringify(sanitized));}catch(e){} return sanitized; } function _orderedComposerControlDefsForSettings(defs){ defs=Array.isArray(defs)?defs:[]; const byKey=new Map(defs.map(def=>[def.key,def])); const out=[]; _getComposerControlOrder().forEach(function(key){ if(byKey.has(key)) out.push(byKey.get(key)); }); defs.forEach(function(def){if(out.indexOf(def)===-1) out.push(def);}); return out; } function _composerControlOrderGroupKey(key){ const def=_composerControlDefsForSettings().find(item=>item&&item.key===key); return def&&def.orderGroup?def.orderGroup:''; } function _composerControlDropAllowed(sourceKey,targetKey){ if(!sourceKey||!targetKey||sourceKey===targetKey) return false; const sourceGroup=_composerControlOrderGroupKey(sourceKey); const targetGroup=_composerControlOrderGroupKey(targetKey); return !!sourceGroup&&sourceGroup===targetGroup; } function _clearComposerControlDragOver(){ document.querySelectorAll('[data-composer-control-key].drag-over').forEach(function(el){el.classList.remove('drag-over');}); } function _moveComposerControlOrderKey(sourceKey,targetKey){ if(!_composerControlDropAllowed(sourceKey,targetKey)) return false; const order=_orderedComposerControlDefsForSettings(_composerControlDefsForSettings()).map(def=>def.key); const from=order.indexOf(sourceKey); const to=order.indexOf(targetKey); if(from===-1||to===-1) return false; order.splice(from,1); order.splice(to,0,sourceKey); const next=_setComposerControlOrder(order); if(typeof window._applyComposerControlOrder==='function') window._applyComposerControlOrder(next); _renderComposerControlChips(); _renderComposerSituationalControlChips(); _scheduleAppearanceAutosave(); return true; } function _handleComposerControlChipDrop(e,targetKey){ if(e){e.preventDefault();e.stopPropagation();} _clearComposerControlDragOver(); const sourceKey=e&&e.dataTransfer?e.dataTransfer.getData('text/plain'):_composerControlDraggingKey; if(_moveComposerControlOrderKey(sourceKey,targetKey)) _composerControlDragSuppressUntil=Date.now()+250; _composerControlDraggingKey=''; } function _wireComposerControlChipDrag(chip,key){ if(!chip)return; chip.setAttribute('data-composer-control-key',key); chip.setAttribute('draggable','true'); chip.addEventListener('dragstart',function(e){ _composerControlDraggingKey=key; chip.classList.add('dragging'); if(e.dataTransfer){ e.dataTransfer.effectAllowed='move'; e.dataTransfer.setData('text/plain',key); } }); chip.addEventListener('dragend',function(){ chip.classList.remove('dragging'); _clearComposerControlDragOver(); _composerControlDraggingKey=''; }); chip.addEventListener('dragover',function(e){ const sourceKey=_composerControlDraggingKey; if(!_composerControlDropAllowed(sourceKey,key)){ if(e.dataTransfer)e.dataTransfer.dropEffect='none'; return; } e.preventDefault(); chip.classList.add('drag-over'); if(e.dataTransfer)e.dataTransfer.dropEffect='move'; }); chip.addEventListener('dragleave',function(){chip.classList.remove('drag-over');}); chip.addEventListener('drop',function(e){_handleComposerControlChipDrop(e,key);}); } function _composerControlVisibilityPayload(){ const payload={}; const defs=_composerControlDefsForSettings(); const state=window._composerControlVisibility||{}; defs.forEach(function(def){payload[def.key]=!!state[def.key];}); return payload; } function _toggleComposerControlChip(key){ if(!window._composerControlVisibility) window._composerControlVisibility={}; window._composerControlVisibility[key]=!window._composerControlVisibility[key]; if(typeof _renderComposerControlChips==='function') _renderComposerControlChips(); if(typeof _renderComposerSituationalControlChips==='function') _renderComposerSituationalControlChips(); if(typeof _applyComposerFooterVisibilitySettings==='function') _applyComposerFooterVisibilitySettings(); _scheduleAppearanceAutosave(); } function _composerControlChipLabel(def){ if(!def) return ''; if(def.labelKey&&typeof t==='function'){ const localized=t(def.labelKey); if(typeof localized==='string'&&localized&&localized!==def.labelKey) return localized; } return def.label||''; } function _renderComposerControlChips(){ const container=$('composerControlsChips'); if(!container) return; const defs=Array.isArray(window._COMPOSER_CONTROL_TOGGLE_DEFS)?window._COMPOSER_CONTROL_TOGGLE_DEFS:[]; const state=window._composerControlVisibility||{}; container.innerHTML=''; _orderedComposerControlDefsForSettings(defs).forEach(function(def){ const chip=document.createElement('button'); chip.type='button'; chip.className='tab-visibility-chip'; const hidden=!!state[def.key]; if(hidden) chip.classList.add('chip-off'); chip.textContent=_composerControlChipLabel(def); chip.setAttribute('role','switch'); chip.setAttribute('aria-checked',hidden?'false':'true'); chip.onclick=function(){if(Date.now()<_composerControlDragSuppressUntil)return;_toggleComposerControlChip(def.key);}; _wireComposerControlChipDrag(chip,def.key); container.appendChild(chip); }); } function _renderComposerSituationalControlChips(){ const container=$('composerSituationalControlsChips'); if(!container) return; const defs=Array.isArray(window._COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS)?window._COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS:[]; const state=window._composerControlVisibility||{}; container.innerHTML=''; _orderedComposerControlDefsForSettings(defs).forEach(function(def){ const chip=document.createElement('button'); chip.type='button'; chip.className='tab-visibility-chip'; const hidden=!!state[def.key]; if(hidden) chip.classList.add('chip-off'); chip.textContent=_composerControlChipLabel(def); chip.setAttribute('role','switch'); chip.setAttribute('aria-checked',hidden?'false':'true'); chip.onclick=function(){if(Date.now()<_composerControlDragSuppressUntil)return;_toggleComposerControlChip(def.key);}; _wireComposerControlChipDrag(chip,def.key); container.appendChild(chip); }); } function switchSettingsSection(name,opts){ // If the main content is not showing settings, just remember the section // without force-switching the panel. The section will be applied when the // user next opens settings via switchPanel(). (#appearance-auto-reopen) if (_currentPanel !== 'settings') { _currentSettingsSection = name; _settingsSection = name; return; } let section=(name==='appearance'||name==='preferences'||name==='providers'||name==='plugins'||name==='extensions'||name==='system'||name==='help')?name:'conversation'; // Deep-linking to the Plugins pane when the tab is hidden (no plugins // installed, #3457) falls back to Conversation. Resolve this BEFORE toggling // panes/sidebar/dropdown below so every downstream selection uses the // corrected section — otherwise the plugins pane would still render active // but empty. (#3457) if(section==='plugins'){ const pluginsTabBtn=document.querySelector('[data-settings-section="plugins"]'); if(pluginsTabBtn && pluginsTabBtn.style.display==='none') section='conversation'; } _settingsSection=section; _currentSettingsSection=section; const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',providers:'Providers',plugins:'Plugins',extensions:'Extensions',system:'System',help:'Help'}; // 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','plugins','extensions','system','help'].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 integration panels when their tabs are opened. Search // navigation passes skipLazyLoad: the loaders rebuild the pane DOM from a // fresh fetch, which would detach the field it is about to scroll to. if(!(opts&&opts.skipLazyLoad)){ if(section==='providers') loadProvidersPanel(); if(section==='plugins') loadPluginsPanel(); if(section==='extensions') loadExtensionsPanel(); } if(opts&&opts.fromSidebarItem)_closeMobileSidebarAfterPanelSelection(); } function _normalizeSettingsSearchText(value) { return String(value || '') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function _extractSettingsDescriptionText(field, labelEl) { const chunks = []; const settingsSearch = (field.dataset && field.dataset.settingsSearch) || ''; if (settingsSearch) chunks.push(settingsSearch); field.querySelectorAll('[data-i18n]').forEach(node => { if (labelEl && (node === labelEl || labelEl.contains(node))) return; const key = node.dataset ? node.dataset.i18n : null; if (key) chunks.push(t(key)); }); return chunks.join(' '); } function _extractSettingsValueText(field) { const chunks = []; const controls = [...field.querySelectorAll('select, input, textarea')]; controls.forEach(control => { const tagName = (control.tagName || '').toLowerCase(); if (tagName === 'select') { control.querySelectorAll('option').forEach(option => { if (option.dataset && option.dataset.i18n) { chunks.push(t(option.dataset.i18n)); } else { chunks.push(option.textContent); } }); return; } const type = (control.getAttribute && control.getAttribute('type')) || control.type || ''; if (tagName === 'input' && ['checkbox', 'radio', 'file', 'submit', 'reset', 'button'].includes(type)) return; if (control.value) chunks.push(control.value); }); return chunks.join(' '); } async function _buildSettingsIndex() { if (_settingsIndex) return; // Memoize the in-flight build so concurrent searches share one pass; the // lazy pane loaders are not guaranteed re-entrant. if (_settingsIndexPromise) return _settingsIndexPromise; const promise = (async () => { // Ensure lazy-loaded panes are populated before reading the DOM await Promise.all([loadProvidersPanel(), loadPluginsPanel(), loadExtensionsPanel()]); const index = []; const add = (entry) => { index.push({ ...entry, _settingsSearchIndex: index.length }); }; const sectionMap = { settingsPaneConversation: 'conversation', settingsPaneAppearance: 'appearance', settingsPanePreferences: 'preferences', settingsPaneProviders: 'providers', settingsPanePlugins: 'plugins', settingsPaneExtensions: 'extensions', settingsPaneSystem: 'system', settingsPaneHelp: 'help', }; for (const [paneId, sectionKey] of Object.entries(sectionMap)) { const pane = $(paneId); if (!pane) continue; pane.querySelectorAll('.settings-field').forEach(field => { // The i18n key may live on the