let _currentPanel = 'chat';
let _skillsData = null; // cached skills list
async function switchPanel(name) {
_currentPanel = name;
// Update nav tabs
document.querySelectorAll('.nav-tab').forEach(t => t.classList.toggle('active', t.dataset.panel === name));
// Update panel views
document.querySelectorAll('.panel-view').forEach(p => p.classList.remove('active'));
const panelEl = $('panel' + name.charAt(0).toUpperCase() + name.slice(1));
if (panelEl) panelEl.classList.add('active');
// Lazy-load panel data
if (name === 'tasks') await loadCrons();
if (name === 'skills') await loadSkills();
if (name === 'memory') await loadMemory();
if (name === 'profiles') await loadProfilesPanel();
if (name === 'agents') await loadAgentsPanel();
if (name === 'heartbeats') await loadHeartbeatsPanel();
if (name === 'projects') await loadProjectsPanel();
}
// ── Cron panel ──
// ── Relative time helpers ──────────────────────────────────────────────────
function _relTime(dateStr) {
if (!dateStr) return null;
const diff = Date.now() - new Date(dateStr).getTime();
const abs = Math.abs(diff);
const mins = Math.floor(abs / 60000);
const hours = Math.floor(abs / 3600000);
const days = Math.floor(abs / 86400000);
const future = diff < 0;
if (mins < 1) return future ? 'in few sec' : 'just now';
if (mins < 60) return future ? `in ${mins}m` : `${mins}m ago`;
if (hours < 24) return future ? `in ${hours}h` : `${hours}h ago`;
if (days === 1) return future ? 'tomorrow' : 'yesterday';
return future ? `in ${days}d` : `${days}d ago`;
}
function _nextIn(dateStr) {
if (!dateStr) return '—';
const diff = new Date(dateStr).getTime() - Date.now();
if (diff <= 0) return 'now';
const mins = Math.floor(diff / 60000);
if (mins < 60) return `in ${mins} min`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `in ${hours}h ${mins % 60}m`;
return `in ${Math.floor(hours / 24)}d`;
}
function _friendlySchedule(expr) {
if (!expr) return '';
// humanize common patterns
if (/^\d+\s+h$/.test(expr)) return `every ${expr.replace('h', ' hour')}s`;
if (/^\d+\s+m$/.test(expr)) return `every ${expr.replace('m', ' min')}s`;
if (/\bat\b|\b\d{1,2}:\d{2}/.test(expr)) return expr;
return expr;
}
async function loadCrons() {
const box = $('cronList');
try {
const data = await api('/api/crons');
if (!data.jobs || !data.jobs.length) {
box.innerHTML = `
${esc(t('cron_no_jobs'))}
`;
return;
}
box.innerHTML = '';
for (const job of data.jobs) {
const item = document.createElement('div');
item.className = 'cron-item';
item.id = 'cron-' + job.id;
const statusClass = job.enabled === false ? 'disabled' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active';
const statusLabel = job.enabled === false ? 'OFF' : job.state === 'paused' ? 'PAUSED' : job.last_status === 'error' ? 'ERROR' : 'ACTIVE';
const next = job.next_run_at ? _nextIn(job.next_run_at) : '—';
const last = job.last_run_at ? _relTime(job.last_run_at) : 'never';
const schedule = job.schedule_display || job.schedule?.expression || '';
const scheduleFriendly = _friendlySchedule(schedule);
const prompt = job.prompt || '';
const promptPreview = prompt.length > 120 ? prompt.slice(0, 120) + '…' : prompt;
item.innerHTML = `
Schedule
${esc(schedule)}
Next run
${esc(next)}
Last ran
${esc(last)}
${promptPreview ? `
Prompt${esc(promptPreview)}
` : ''}
${job.state==='paused'
? ``
: ``}
`;
box.appendChild(item);
loadCronOutput(job.id);
}
} catch(e) { box.innerHTML = `${esc(t('error_prefix'))}${esc(e.message)}
`; }
}
let _cronSelectedSkills=[];
let _cronSkillsCache=null;
function toggleCronForm(){
const form=$('cronCreateForm');
if(!form)return;
const open=form.style.display!=='none';
form.style.display=open?'none':'';
if(!open){
$('cronFormName').value='';
$('cronFormSchedule').value='';
$('cronFormPrompt').value='';
$('cronFormDeliver').value='local';
$('cronFormError').style.display='none';
_cronSelectedSkills=[];
_renderCronSkillTags();
const search=$('cronFormSkillSearch');
if(search)search.value='';
// Always re-fetch skills to avoid stale cache
_cronSkillsCache=null;
api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[];}).catch(()=>{});
$('cronFormName').focus();
}
}
function _renderCronSkillTags(){
const wrap=$('cronFormSkillTags');
if(!wrap)return;
wrap.innerHTML='';
for(const name of _cronSelectedSkills){
const tag=document.createElement('span');
tag.className='skill-tag';
tag.dataset.skill=name;
const rm=document.createElement('span');
rm.className='remove-tag';rm.textContent='×';
rm.onclick=()=>{_cronSelectedSkills=_cronSelectedSkills.filter(s=>s!==name);tag.remove();};
tag.appendChild(document.createTextNode(name));
tag.appendChild(rm);
wrap.appendChild(tag);
}
}
// Skill search input handler
(function(){
const setup=()=>{
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);
};
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',setup);
else setTimeout(setup,0);
})();
async function submitCronCreate(){
const name=$('cronFormName').value.trim();
const schedule=$('cronFormSchedule').value.trim();
const prompt=$('cronFormPrompt').value.trim();
const deliver=$('cronFormDeliver').value;
const errEl=$('cronFormError');
errEl.style.display='none';
if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;}
if(!prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;}
try{
const body={schedule,prompt,deliver};
if(name)body.name=name;
if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills;
await api('/api/crons/create',{method:'POST',body:JSON.stringify(body)});
toggleCronForm();
showToast(t('cron_job_created'));
await loadCrons();
}catch(e){
errEl.textContent=t('error_prefix')+e.message;errEl.style.display='';
}
}
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)';
}
async function loadCronOutput(jobId) {
try {
const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=1`);
const el = $('cron-out-text-' + jobId);
if (!el) return;
if (!data.outputs || !data.outputs.length) { el.textContent = t('cron_no_runs_yet'); return; }
const out = data.outputs[0];
const ts = out.filename.replace('.md','').replace(/_/g,' ');
el.textContent = ts + '\n\n' + _cronOutputSnippet(out.content);
} catch(e) { /* ignore */ }
}
async function loadCronHistory(jobId, btn) {
const histEl = $('cron-history-' + jobId);
if (!histEl) return;
// Toggle: if already open, close it
if (histEl.style.display !== 'none') {
histEl.style.display = 'none';
if (btn) btn.textContent = t('cron_all_runs');
return;
}
if (btn) btn.textContent = t('loading');
try {
const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20`);
if (!data.outputs || !data.outputs.length) {
histEl.innerHTML = `${esc(t('cron_no_runs_yet'))}
`;
} else {
histEl.innerHTML = data.outputs.map((out, i) => {
const ts = out.filename.replace('.md','').replace(/_/g,' ');
const snippet = _cronOutputSnippet(out.content);
const id = `cron-hist-run-${jobId}-${i}`;
return `
${esc(ts)}
▸
${esc(snippet)}
`;
}).join('');
}
histEl.style.display = '';
if (btn) btn.textContent = t('cron_hide_runs');
} catch(e) {
if (btn) btn.textContent = t('cron_all_runs');
}
}
function toggleCron(id) {
const body = $('cron-body-' + id);
if (body) body.classList.toggle('open');
}
async function cronRun(id) {
try {
await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})});
showToast(t('cron_job_triggered'));
setTimeout(() => loadCronOutput(id), 5000);
} 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); }
}
function cronEditOpen(id, job) {
const form = $('cron-edit-' + id);
if (!form) return;
$('cron-edit-name-' + id).value = job.name || '';
$('cron-edit-schedule-' + id).value = job.schedule_display || (job.schedule && job.schedule.expression) || job.schedule || '';
$('cron-edit-prompt-' + id).value = job.prompt || '';
const errEl = $('cron-edit-err-' + id);
if (errEl) errEl.style.display = 'none';
form.style.display = '';
}
function cronEditClose(id) {
const form = $('cron-edit-' + id);
if (form) form.style.display = 'none';
}
async function cronEditSave(id) {
const name = $('cron-edit-name-' + id).value.trim();
const schedule = $('cron-edit-schedule-' + id).value.trim();
const prompt = $('cron-edit-prompt-' + id).value.trim();
const errEl = $('cron-edit-err-' + id);
if (!schedule) { errEl.textContent = t('cron_schedule_required'); errEl.style.display = ''; return; }
if (!prompt) { errEl.textContent = t('cron_prompt_required'); errEl.style.display = ''; return; }
try {
const updates = {job_id: id, schedule, prompt};
if (name) updates.name = name;
await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)});
showToast(t('cron_job_updated'));
await loadCrons();
} catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; }
}
async function cronDelete(id) {
const _delCron=await showConfirmDialog({title:t('cron_delete_confirm_title'),message:t('cron_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true});
if(!_delCron) return;
try {
await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})});
showToast(t('cron_job_deleted'));
await loadCrons();
} catch(e) { showToast(t('delete_failed') + e.message, 4000); }
}
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 || [];
renderSkills(_skillsData);
} catch(e) { box.innerHTML = `Error: ${esc(e.message)}
`; }
}
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 sec = document.createElement('div');
sec.className = 'skills-category';
const displayName = cat === 'general' ? 'General' : cat;
// Collapsible category header
const collapsed = _collapsedCats && _collapsedCats.has(cat);
sec.innerHTML = `
`;
const itemsDiv = sec.querySelector('.skills-cat-items');
for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) {
const el = document.createElement('div');
el.className = 'skill-item';
el.innerHTML = `${esc(skill.name)}${esc(skill.description||'')}`;
el.onclick = () => openSkill(skill.name, el);
itemsDiv.appendChild(el);
}
box.appendChild(sec);
}
}
// Track collapsed categories
let _collapsedCats = new Set();
function toggleSkillCat(headerEl) {
const cat = headerEl.dataset.cat;
const itemsDiv = headerEl.nextElementSibling;
const chevron = headerEl.querySelector('.cat-chevron');
const collapsed = itemsDiv.style.display === 'none';
if (collapsed) {
itemsDiv.style.display = '';
chevron.textContent = '▼';
_collapsedCats.delete(cat);
} else {
itemsDiv.style.display = 'none';
chevron.textContent = '▶';
_collapsedCats.add(cat);
}
}
function filterSkills() {
if (_skillsData) renderSkills(_skillsData);
}
async function openSkill(name, el) {
// Highlight active skill
document.querySelectorAll('.skill-item').forEach(e => e.classList.remove('active'));
if (el) el.classList.add('active');
try {
const data = await api(`/api/skills/content?name=${encodeURIComponent(name)}`);
// Show skill content in right panel preview
$('previewPathText').textContent = name + '.md';
$('previewBadge').textContent = 'skill';
$('previewBadge').className = 'preview-badge md';
showPreview('md');
let html = renderMd(data.content || '(no content)');
// Render linked files section if present
const lf = data.linked_files || {};
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 += '
';
}
$('previewMd').innerHTML = html;
// Wire linked-file clicks via data attributes (avoids inline JS XSS with apostrophes)
$('previewMd').querySelectorAll('.skill-linked-file').forEach(a=>{
a.addEventListener('click',e=>{e.preventDefault();openSkillFile(a.dataset.skillName,a.dataset.skillFile);});
});
$('previewArea').classList.add('visible');
$('fileTree').style.display = 'none';
const wsSearchSkill=$('wsSearchWrap');if(wsSearchSkill)wsSearchSkill.style.display='none';
} 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)}`);
$('previewPathText').textContent = skillName + ' / ' + filePath;
$('previewBadge').textContent = filePath.split('.').pop() || 'file';
$('previewBadge').className = 'preview-badge code';
const ext = filePath.split('.').pop() || '';
if (['md','markdown'].includes(ext)) {
showPreview('md');
$('previewMd').innerHTML = renderMd(data.content || '');
} else {
showPreview('code');
if (['yml','yaml'].includes(ext)) {
$('previewCode').textContent = '';
$('previewCode').className = 'preview-code hl-yaml';
$('previewCode').innerHTML = highlightYAML(data.content || '');
} else {
$('previewCode').className = 'preview-code';
$('previewCode').textContent = data.content || '';
}
requestAnimationFrame(() => highlightCode());
}
} catch(e) { setStatus(t('skill_file_load_failed') + e.message); }
}
// ── YAML syntax highlighter with line numbers ──
function highlightYAML(text) {
const lines = text.split('\n');
return lines.map(raw => {
// Escape HTML first
let line = esc(raw);
// Highlight full-line comments
if (/^\s*#/.test(line)) {
return '';
}
// Highlight inline comments (after a value)
line = line.replace(/(#.*)$/, '');
// Highlight strings (double or single quoted)
line = line.replace(/("[^&]*?"|'[^&]*?')/g, '$1');
// Highlight key: value pairs — key before the colon
line = line.replace(/^(\s*)([\w._-]+)(:)/, '$1$2$3');
return '' + line + '';
}).join('\n');
}
// ── Skill create/edit form ──
let _editingSkillName = null;
function toggleSkillForm(prefillName, prefillCategory, prefillContent) {
const form = $('skillCreateForm');
if (!form) return;
const open = form.style.display !== 'none';
if (open) { form.style.display = 'none'; _editingSkillName = null; return; }
$('skillFormName').value = prefillName || '';
$('skillFormCategory').value = prefillCategory || '';
$('skillFormContent').value = prefillContent || '';
$('skillFormError').style.display = 'none';
_editingSkillName = prefillName || null;
form.style.display = '';
$('skillFormName').focus();
}
async function submitSkillSave() {
const name = ($('skillFormName').value||'').trim().toLowerCase().replace(/\s+/g, '-');
const category = ($('skillFormCategory').value||'').trim();
const content = $('skillFormContent').value;
const errEl = $('skillFormError');
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;
toggleSkillForm();
await loadSkills();
} catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; }
}
// ── Memory inline edit ──
let _memoryData = null;
function toggleMemoryEdit() {
const form = $('memoryEditForm');
if (!form) return;
const open = form.style.display !== 'none';
if (open) { form.style.display = 'none'; return; }
$('memEditSection').textContent = t('memory_notes_label');
$('memEditContent').value = _memoryData ? (_memoryData.memory || '') : '';
$('memEditError').style.display = 'none';
form.style.display = '';
}
function closeMemoryEdit() {
const form = $('memoryEditForm');
if (form) form.style.display = 'none';
}
async function submitMemorySave() {
const content = $('memEditContent').value;
const errEl = $('memEditError');
errEl.style.display = 'none';
try {
await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: 'memory', content})});
showToast(t('memory_saved'));
closeMemoryEdit();
await loadMemory(true);
} catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; }
}
// ── Workspace management ──
let _workspaceList = []; // cached from /api/workspaces
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);
const ws=hasSession?S.session.workspace:'';
const label=hasSession?getWorkspaceFriendlyName(ws):t('no_workspace');
const sidebarName=$('sidebarWsName');
const sidebarPath=$('sidebarWsPath');
if(sidebarName) sidebarName.textContent=label;
if(sidebarPath) sidebarPath.textContent=ws;
// Topbar wsSelector
const wsSelLabel=$('wsSelectorLabel');
const wsSelChip=$('wsSelectorChip');
if(wsSelLabel) wsSelLabel.textContent=label;
if(wsSelChip) wsSelChip.title=hasSession?ws:t('no_workspace');
const composerChip=$('composerWorkspaceChip');
const composerLabel=$('composerWorkspaceLabel');
const composerDropdown=$('composerWsDropdown');
if(!hasSession && composerDropdown) composerDropdown.classList.remove('open');
if(composerLabel) composerLabel.textContent=label;
if(composerChip){
composerChip.disabled=!hasSession;
composerChip.title=hasSession?ws:t('no_workspace');
composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open')));
}
}
async function loadWorkspaceList(){
try{
const data = await api('/api/workspaces');
_workspaceList = data.workspaces || [];
syncWorkspaceDisplays();
return data;
}catch(e){ return {workspaces:[], last:''}; }
}
function _renderWorkspaceAction(label, meta, iconSvg, onClick){
const opt=document.createElement('div');
opt.className='ws-opt ws-opt-action';
opt.innerHTML=`${iconSvg}${esc(label)}${meta?`${esc(meta)}`:''}`;
opt.onclick=onClick;
return opt;
}
function _positionComposerWsDropdown(){
const dd=$('composerWsDropdown');
const chip=$('composerWorkspaceChip');
const footer=document.querySelector('.composer-footer');
if(!dd||!chip||!footer)return;
const chipRect=chip.getBoundingClientRect();
const footerRect=footer.getBoundingClientRect();
let left=chipRect.left-footerRect.left;
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
left=Math.max(0, Math.min(left, maxLeft));
dd.style.left=`${left}px`;
}
function _positionProfileDropdown(){
const dd=$('profileDropdown');
const chip=$('profileChip');
const footer=document.querySelector('.composer-footer');
if(!dd||!chip||!footer)return;
const chipRect=chip.getBoundingClientRect();
const footerRect=footer.getBoundingClientRect();
let left=chipRect.left-footerRect.left;
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
left=Math.max(0, Math.min(left, maxLeft));
dd.style.left=`${left}px`;
}
function renderWorkspaceDropdownInto(dd, workspaces, currentWs){
if(!dd)return;
dd.innerHTML='';
for(const w of workspaces){
const opt=document.createElement('div');
opt.className='ws-opt'+(w.path===currentWs?' active':'');
opt.innerHTML=`${esc(w.name)}${esc(w.path)}`;
opt.onclick=()=>switchToWorkspace(w.path,w.name);
dd.appendChild(opt);
}
dd.appendChild(document.createElement('div')).className='ws-divider';
dd.appendChild(_renderWorkspaceAction(
t('workspace_choose_path'),
t('workspace_choose_path_meta'),
li('folder',12),
()=>promptWorkspacePath()
));
const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div);
dd.appendChild(_renderWorkspaceAction(
t('workspace_manage'),
t('workspace_manage_meta'),
li('settings',12),
()=>{closeWsDropdown();mobileSwitchPanel('workspaces');}
));
}
function toggleWsDropdown(){
const dd=$('wsDropdown');
if(!dd)return;
const open=dd.classList.contains('open');
if(open){closeWsDropdown();}
else{
closeProfileDropdown(); // close profile dropdown if open
loadWorkspaceList().then(data=>{
renderWorkspaceDropdownInto(dd, data.workspaces, S.session?S.session.workspace:'');
dd.classList.add('open');
});
}
}
function toggleComposerWsDropdown(){
const dd=$('composerWsDropdown');
const chip=$('composerWorkspaceChip');
if(!dd||!chip||chip.disabled)return;
const open=dd.classList.contains('open');
if(open){closeWsDropdown();}
else{
closeProfileDropdown();
if(typeof closeModelDropdown==='function') closeModelDropdown();
loadWorkspaceList().then(data=>{
renderWorkspaceDropdownInto(dd, data.workspaces, S.session?S.session.workspace:'');
dd.classList.add('open');
_positionComposerWsDropdown();
chip.classList.add('active');
});
}
}
// ── Topbar Workspace Selector Dropdown ──
let _wsSelectorOpen = false;
function toggleWsSelectorDropdown(){
const dd=$('wsSelectorDropdown');
const chip=$('wsSelectorChip');
if(!dd)return;
_wsSelectorOpen=!_wsSelectorOpen;
if(_wsSelectorOpen){
loadWorkspaceList().then(data=>{
renderWsSelectorList(data.workspaces||[]);
dd.style.display='';
chip.classList.add('active');
});
}else{
closeWsSelectorDropdown();
}
}
function closeWsSelectorDropdown(){
const dd=$('wsSelectorDropdown');
const chip=$('wsSelectorChip');
_wsSelectorOpen=false;
if(dd)dd.style.display='none';
if(chip)chip.classList.remove('active');
}
function renderWsSelectorList(workspaces){
const dd=$('wsSelectorDropdown');
if(!dd)return;
const current=S.session?S.session.workspace:'';
dd.innerHTML='';
if(!workspaces.length){
dd.innerHTML='Keine Workspaces
';
return;
}
for(const w of workspaces){
const row=document.createElement('div');
row.style.cssText='display:flex;align-items:center;justify-content:space-between;padding:8px 14px;cursor:pointer;font-size:12px;color:var(--text);border-radius:6px;margin:2px 4px;transition:background .1s';
row.onmouseenter=()=>row.style.background='var(--hover-bg)';
row.onmouseleave=()=>row.style.background='';
const isActive=w.path===current||w.path===current;
const left=document.createElement('div');
left.style.cssText='display:flex;flex-direction:column;gap:2px;min-width:0';
left.innerHTML=`${esc(w.name||w.path)}
${esc(w.path)}
`;
const right=document.createElement('div');
if(isActive) right.innerHTML=`✓`;
row.appendChild(left);
row.appendChild(right);
row.onclick=()=>{switchToWorkspace(w.path,w.name||w.path);closeWsSelectorDropdown();};
dd.appendChild(row);
}
}
document.addEventListener('click',e=>{
if(!e.target.closest('#wsSelectorWrap')) closeWsSelectorDropdown();
});
function closeWsDropdown(){
const dd=$('wsDropdown');
const composerDd=$('composerWsDropdown');
const composerChip=$('composerWorkspaceChip');
if(dd)dd.classList.remove('open');
if(composerDd)composerDd.classList.remove('open');
if(composerChip)composerChip.classList.remove('active');
}
document.addEventListener('click',e=>{
if(
!e.target.closest('#composerWorkspaceChip') &&
!e.target.closest('#composerWsDropdown')
) closeWsDropdown();
});
window.addEventListener('resize',()=>{
const dd=$('composerWsDropdown');
if(dd&&dd.classList.contains('open')) _positionComposerWsDropdown();
});
async function loadWorkspacesPanel(){
const panel=$('workspacesPanel');
if(!panel)return;
const data=await loadWorkspaceList();
renderWorkspacesPanel(data.workspaces);
}
function renderWorkspacesPanel(workspaces){
const panel=$('workspacesPanel');
panel.innerHTML='';
for(const w of workspaces){
const row=document.createElement('div');row.className='ws-row';
row.innerHTML=`
${esc(w.name)}
${esc(w.path)}
`;
panel.appendChild(row);
}
const addRow=document.createElement('div');addRow.className='ws-add-row';
addRow.innerHTML=`
`;
panel.appendChild(addRow);
const hint=document.createElement('div');
hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px';
hint.textContent=t('workspace_paths_validated_hint');
panel.appendChild(hint);
}
async function addWorkspace(){
const input=$('wsAddInput');
const path=(input?input.value:'').trim();
if(!path)return;
try{
const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})});
_workspaceList=data.workspaces;
renderWorkspacesPanel(data.workspaces);
if(input)input.value='';
showToast(t('workspace_added'));
}catch(e){setStatus(t('add_failed')+e.message);}
}
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(){
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){
if(!S.session)return;
if(S.busy){
showToast(t('workspace_busy_switch'));
return;
}
if(typeof _previewDirty!=='undefined'&&_previewDirty){
const discard=await showConfirmDialog({
title:t('discard_file_edits_title'),
message:t('discard_file_edits_message'),
confirmLabel:t('discard'),
danger:true
});
if(!discard)return;
if(typeof cancelEditMode==='function')cancelEditMode();
if(typeof clearPreview==='function')clearPreview();
}
try{
closeWsDropdown();
await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id, workspace:path, model:S.session.model
})});
S.session.workspace=path;
syncTopbar();
await loadDir('.');
showToast(t('workspace_switched_to',name||getWorkspaceFriendlyName(path)));
}catch(e){setStatus(t('switch_failed')+e.message);}
}
// ── Profile panel + dropdown ──
let _profilesCache = null;
async function loadProfilesPanel() {
const panel = $('profilesPanel');
if (!panel) return;
try {
const data = await api('/api/profiles');
_profilesCache = data;
panel.innerHTML = '';
if (!data.profiles || !data.profiles.length) {
panel.innerHTML = `${esc(t('profiles_no_profiles'))}
`;
return;
}
for (const p of data.profiles) {
const card = document.createElement('div');
card.className = 'profile-card';
const meta = [];
if (p.model) meta.push(p.model.split('/').pop());
if (p.provider) meta.push(p.provider);
if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count));
if (p.has_env) meta.push(t('profile_api_keys_configured'));
const gwDot = p.gateway_running
? ``
: ``;
const isActive = p.name === data.active;
const activeBadge = isActive ? `${esc(t('profile_active'))}` : '';
card.innerHTML = `
`;
panel.appendChild(card);
}
} catch (e) {
panel.innerHTML = `Error: ${esc(e.message)}
`;
}
}
function renderProfileDropdown(data) {
const dd = $('profileDropdown');
if (!dd) return;
dd.innerHTML = '';
const profiles = data.profiles || [];
const active = data.active || 'default';
for (const p of profiles) {
const opt = document.createElement('div');
opt.className = 'profile-opt' + (p.name === active ? ' active' : '');
const meta = [];
if (p.model) meta.push(p.model.split('/').pop());
if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count));
const gwDot = ``;
const checkmark = p.name === active ? ' ' : '';
opt.innerHTML = `${gwDot}${esc(p.name)}${p.is_default ? ' (default)' : ''}${checkmark}
` +
(meta.length ? `${esc(meta.join(' \u00b7 '))}
` : '');
opt.onclick = async () => {
closeProfileDropdown();
if (p.name === active) return;
await switchToProfile(p.name);
};
dd.appendChild(opt);
}
// Divider + Manage link
const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div);
const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage';
mgmt.innerHTML = `${li('settings',12)} ${esc(t('manage_profiles'))}`;
mgmt.onclick = () => { closeProfileDropdown(); mobileSwitchPanel('profiles'); };
dd.appendChild(mgmt);
}
function toggleProfileDropdown() {
const dd = $('profileDropdown');
if (!dd) return;
if (dd.classList.contains('open')) { closeProfileDropdown(); return; }
closeWsDropdown(); // close workspace dropdown if open
if(typeof closeModelDropdown==='function') closeModelDropdown();
api('/api/profiles').then(data => {
renderProfileDropdown(data);
dd.classList.add('open');
_positionProfileDropdown();
const chip=$('profileChip');
if(chip) chip.classList.add('active');
}).catch(e => { showToast(t('profiles_load_failed')); });
}
function closeProfileDropdown() {
const dd = $('profileDropdown');
if (dd) dd.classList.remove('open');
const chip=$('profileChip');
if(chip) chip.classList.remove('active');
}
document.addEventListener('click', e => {
if (!e.target.closest('#profileChipWrap') && !e.target.closest('#profileDropdown')) closeProfileDropdown();
});
window.addEventListener('resize',()=>{
const dd=$('profileDropdown');
if(dd&&dd.classList.contains('open')) _positionProfileDropdown();
});
async function switchToProfile(name) {
if (S.busy) { showToast(t('profiles_busy_switch')); return; }
// Determine whether the current session has any messages.
// A session with messages is "in progress" and belongs to the current profile —
// we must not retag it. We'll start a fresh session for the new profile instead.
const sessionInProgress = S.session && S.messages && S.messages.length > 0;
try {
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
S.activeProfile = data.active || name;
// ── Model ──────────────────────────────────────────────────────────────
localStorage.removeItem('hermes-webui-model');
_skillsData = null;
await populateModelDropdown();
if (data.default_model) {
const sel = $('modelSelect');
const resolved = _applyModelToDropdown(data.default_model, sel);
const modelToUse = resolved || data.default_model;
S._pendingProfileModel = modelToUse;
// Only patch the in-memory session model if we're NOT about to replace the session
if (S.session && !sessionInProgress) {
S.session.model = modelToUse;
}
}
// ── Workspace ──────────────────────────────────────────────────────────
_workspaceList = null;
await loadWorkspaceList();
if (data.default_workspace) {
// Always store the profile default for new sessions
S._profileDefaultWorkspace = data.default_workspace;
if (S.session && !sessionInProgress) {
// Empty session (no messages yet) — safe to update it in place
try {
await api('/api/session/update', { method: 'POST', body: JSON.stringify({
session_id: S.session.session_id,
workspace: data.default_workspace,
model: S.session.model,
})});
S.session.workspace = data.default_workspace;
} catch (_) {}
}
}
// ── Session ────────────────────────────────────────────────────────────
_showAllProfiles = false;
if (sessionInProgress) {
// The current session has messages and belongs to the previous profile.
// Start a new session for the new profile so nothing gets cross-tagged.
await newSession(false);
// Apply profile default workspace to the newly created session (fixes #424)
if (S._profileDefaultWorkspace && S.session) {
try {
await api('/api/session/update', { method: 'POST', body: JSON.stringify({
session_id: S.session.session_id,
workspace: S._profileDefaultWorkspace,
model: S.session.model,
})});
S.session.workspace = S._profileDefaultWorkspace;
} catch (_) {}
}
updateWorkspaceChip();
await renderSessionList();
showToast(t('profile_switched_new_conversation', name));
} else {
// No messages yet — just refresh the list and topbar in place
await renderSessionList();
syncTopbar();
showToast(t('profile_switched', name));
}
// ── Sidebar panels ─────────────────────────────────────────────────────
if (_currentPanel === 'skills') await loadSkills();
if (_currentPanel === 'memory') await loadMemory();
if (_currentPanel === 'tasks') await loadCrons();
if (_currentPanel === 'profiles') await loadProfilesPanel();
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
} catch (e) { showToast(t('switch_failed') + e.message); }
}
function toggleProfileForm() {
const form = $('profileCreateForm');
if (!form) return;
form.style.display = form.style.display === 'none' ? '' : 'none';
if (form.style.display !== 'none') {
$('profileFormName').value = '';
$('profileFormClone').checked = false;
if ($('profileFormBaseUrl')) $('profileFormBaseUrl').value = '';
if ($('profileFormApiKey')) $('profileFormApiKey').value = '';
const errEl = $('profileFormError');
if (errEl) errEl.style.display = 'none';
$('profileFormName').focus();
}
}
async function submitProfileCreate() {
const name = ($('profileFormName').value || '').trim().toLowerCase();
const cloneConfig = $('profileFormClone').checked;
const errEl = $('profileFormError');
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; }
try {
const baseUrl = (($('profileFormBaseUrl') && $('profileFormBaseUrl').value) || '').trim();
const apiKey = (($('profileFormApiKey') && $('profileFormApiKey').value) || '').trim();
if (baseUrl && !/^https?:\/\//.test(baseUrl)) {
errEl.textContent = t('profile_base_url_rule'); errEl.style.display = ''; return;
}
const payload = { name, clone_config: cloneConfig };
if (baseUrl) payload.base_url = baseUrl;
if (apiKey) payload.api_key = apiKey;
await api('/api/profile/create', { method: 'POST', body: JSON.stringify(payload) });
toggleProfileForm();
await loadProfilesPanel();
showToast(t('profile_created', name));
} catch (e) {
errEl.textContent = e.message || t('create_failed');
errEl.style.display = '';
}
}
async function deleteProfile(name) {
const _delProf=await showConfirmDialog({title:t('profile_delete_confirm_title',name),message:t('profile_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true});
if(!_delProf) return;
try {
await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) });
await loadProfilesPanel();
showToast(t('profile_deleted', name));
} catch (e) { showToast(t('delete_failed') + e.message); }
}
// ── Gateway panel ──
let _gatewaysCache = null;
async function loadGatewaysPanel() {
const panel = $('gatewaysPanel');
if (!panel) return;
try {
const data = await api('/api/gateways');
_gatewaysCache = data;
panel.innerHTML = '';
if (!data.gateways || !data.gateways.length) {
panel.innerHTML = `${esc(t('gateways_no_gateways'))}
`;
return;
}
for (const gw of data.gateways) {
const card = document.createElement('div');
card.className = 'profile-card';
const statusColor = gw.running ? 'var(--link)' : 'var(--muted)';
const statusDot = gw.running
? ``
: ``;
const statusText = gw.running ? esc(t('gateway_running')) : esc(t('gateway_stopped'));
const typeMeta = gw.type ? `${esc(gw.type)}` : '';
card.innerHTML = `
${gw.info ? `${esc(gw.info)}
` : ''}`;
panel.appendChild(card);
}
// Add gateway button
const addBtn = document.createElement('button');
addBtn.className = 'cron-btn run';
addBtn.style.cssText = 'width:100%;margin-top:8px;padding:6px';
addBtn.innerHTML = `+ ${esc(t('gateway_add'))}`;
addBtn.onclick = () => showAddGatewayDialog();
panel.appendChild(addBtn);
} catch (e) {
panel.innerHTML = `Error: ${esc(e.message)}
`;
}
}
async function startGateway(name) {
try {
await api('/api/gateway/start', { method: 'POST', body: JSON.stringify({ name }) });
showToast(t('gateway_started', name));
await loadGatewaysPanel();
} catch (e) { showToast(t('gateway_start_failed') + e.message); }
}
async function stopGateway(name) {
try {
await api('/api/gateway/stop', { method: 'POST', body: JSON.stringify({ name }) });
showToast(t('gateway_stopped_msg', name));
await loadGatewaysPanel();
} catch (e) { showToast(t('gateway_stop_failed') + e.message); }
}
async function restartGateway(name) {
try {
await api('/api/gateway/restart', { method: 'POST', body: JSON.stringify({ name }) });
showToast(t('gateway_restarted', name));
await loadGatewaysPanel();
} catch (e) { showToast(t('gateway_restart_failed') + e.message); }
}
async function showAddGatewayDialog() {
const name = await showPromptDialog({
title: t('gateway_add_title'),
message: t('gateway_add_message'),
confirmLabel: t('gateway_add'),
placeholder: 'telegram'
});
if (!name) return;
try {
await api('/api/gateway/add', { method: 'POST', body: JSON.stringify({ name, type: 'telegram' }) });
showToast(t('gateway_added', name));
await loadGatewaysPanel();
} catch (e) { showToast(t('gateway_add_failed') + e.message); }
}
// ── Memory panel ──
async function loadMemory(force) {
const panel = $('memoryPanel');
try {
const data = await api('/api/memory');
_memoryData = data; // cache for edit form
const fmtTime = ts => ts ? new Date(ts*1000).toLocaleString() : '';
// Mask sensitive data (API tokens, passwords, keys) in displayed memory
function maskSensitive(md) {
if (!md) return md;
return md
.replace(/([A-Za-z0-9]{32,})\.(?:[A-Za-z0-9+\/=]{10,})/g, '***REDACTED***') // API tokens like Z.ai
.replace(/([Aa]pi[_\s]?[Tt]oken[:\s]*)\S{20,}/g, '$1***REDACTED***') // API Token: xxx
.replace(/([Aa]PI[_\s]?[Kk]ey[:\s]*)\S{20,}/g, '$1***REDACTED***') // API Key: xxx
.replace(/([Pp]assword[:\s]*)\S{8,}/g, '$1***REDACTED***') // Password: xxx
.replace(/([Tt]oken[:\s]*)[a-f0-9]{32,}/g, '$1***REDACTED***') // token: hex
.replace(/([Ss]ecret[:\s]*)\S{16,}/g, '$1***REDACTED***'); // secret: xxx
}
panel.innerHTML = `
${li('brain',14)} ${esc(t('my_notes'))}
${fmtTime(data.memory_mtime)}
${data.memory
? `
${renderMd(maskSensitive(data.memory))}
`
: `
${esc(t('no_notes_yet'))}
`}
${li('user',14)} ${esc(t('user_profile'))}
${fmtTime(data.user_mtime)}
${data.user
? `
${renderMd(maskSensitive(data.user))}
`
: `
${esc(t('no_profile_yet'))}
`}
`;
} catch(e) { panel.innerHTML = `${esc(t('error_prefix'))}${esc(e.message)}
`; }
}
// Drag and drop
const wrap=$('composerWrap');let dragCounter=0;
document.addEventListener('dragover',e=>e.preventDefault());
document.addEventListener('dragenter',e=>{e.preventDefault();if(e.dataTransfer.types.includes('Files')){dragCounter++;wrap.classList.add('drag-over');}});
document.addEventListener('dragleave',e=>{dragCounter--;if(dragCounter<=0){dragCounter=0;wrap.classList.remove('drag-over');}});
document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.classList.remove('drag-over');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 _settingsSection = 'conversation';
function switchSettingsSection(name){
const section=(name==='preferences'||name==='system'||name==='gateways'||name==='logs'||name==='heartbeats')?name:'conversation';
_settingsSection=section;
const map={conversation:'Conversation',preferences:'Preferences',system:'System',gateways:'Gateways',logs:'Logs',heartbeats:'Heartbeats'};
['conversation','preferences','system','gateways','logs','heartbeats'].forEach(key=>{
const tab=$('settingsTab'+map[key]);
const pane=$('settingsPane'+map[key]);
const active=key===section;
if(tab){
tab.classList.toggle('active',active);
tab.setAttribute('aria-selected',active?'true':'false');
}
if(pane) pane.classList.toggle('active',active);
});
if(section==='logs') loadLogsPanel();
if(section==='heartbeats') loadHeartbeatsPanel();
}
function _syncHermesPanelSessionActions(){
const hasSession=!!S.session;
const visibleMessages=hasSession?(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length:0;
const title=hasSession?(S.session.title||t('untitled')):t('active_conversation_none');
const meta=$('hermesSessionMeta');
if(meta){
meta.textContent=hasSession
? t('active_conversation_meta', title, visibleMessages)
: t('active_conversation_none');
}
const setDisabled=(id,disabled)=>{
const el=$(id);
if(!el)return;
el.disabled=!!disabled;
el.classList.toggle('disabled',!!disabled);
};
setDisabled('btnDownload',!hasSession||visibleMessages===0);
setDisabled('btnExportJSON',!hasSession);
setDisabled('btnClearConvModal',!hasSession||visibleMessages===0);
}
function toggleSettings(){
const overlay=$('settingsOverlay');
if(!overlay) return;
if(overlay.style.display==='none'){
_settingsDirty = false;
_settingsThemeOnOpen = localStorage.getItem('hermes-theme') || document.documentElement.dataset.theme || 'dark';
_settingsSection = 'conversation';
overlay.style.display='';
loadSettingsPanel();
} else {
_closeSettingsPanel();
}
}
function _resetSettingsPanelState(){
_settingsSection = 'conversation';
switchSettingsSection('conversation');
const bar=$('settingsUnsavedBar');
if(bar) bar.style.display='none';
}
function _hideSettingsPanel(){
const overlay=$('settingsOverlay');
if(!overlay) return;
_resetSettingsPanelState();
overlay.style.display='none';
}
// Close with unsaved-changes check. If dirty, show a confirm dialog.
function _closeSettingsPanel(){
if(!_settingsDirty){
// Nothing changed -- revert any live preview and close
_revertSettingsPreview();
_hideSettingsPanel();
return;
}
// Dirty -- show inline confirm bar
_showSettingsUnsavedBar();
}
// Revert live DOM/localStorage to what they were when the panel opened
function _revertSettingsPreview(){
if(_settingsThemeOnOpen){
localStorage.setItem('hermes-theme', _settingsThemeOnOpen);
if(typeof _applyTheme==='function') _applyTheme(_settingsThemeOnOpen);
else document.documentElement.dataset.theme = _settingsThemeOnOpen;
}
}
// Show the "Unsaved changes" bar inside the settings panel
function _showSettingsUnsavedBar(){
let bar = $('settingsUnsavedBar');
if(bar){ bar.style.display=''; return; }
// Create it
bar = document.createElement('div');
bar.id = 'settingsUnsavedBar';
bar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.3);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:13px;';
bar.innerHTML = `${esc(t('settings_unsaved_changes'))}`
+ ''
+ ``
+ ``
+ '';
const body = document.querySelector('.settings-main') || document.querySelector('.settings-body') || document.querySelector('.settings-panel');
if(body) body.prepend(bar);
}
function _discardSettings(){
_revertSettingsPreview();
_settingsDirty = false;
_hideSettingsPanel();
}
// Mark settings as dirty whenever anything changes
function _markSettingsDirty(){
_settingsDirty = true;
}
async function loadSettingsPanel(){
try{
const settings=await api('/api/settings');
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
// Keep settings modal and current page strings in sync with the resolved locale.
if(typeof setLocale==='function'){
setLocale(resolvedLanguage);
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
}
// Populate model dropdown from /api/models
const modelSel=$('settingsModel');
if(modelSel){
modelSel.innerHTML='';
try{
const models=await api('/api/models');
for(const g of (models.groups||[])){
const og=document.createElement('optgroup');
og.label=g.provider;
for(const m of g.models){
const opt=document.createElement('option');
opt.value=m.id;opt.textContent=m.label;
og.appendChild(opt);
}
modelSel.appendChild(og);
}
}catch(e){}
modelSel.value=settings.default_model||'';
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
}
// Send key preference
const sendKeySel=$('settingsSendKey');
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});}
// Theme preference
const themeSel=$('settingsTheme');
if(themeSel){themeSel.value=settings.theme||'dark';themeSel.addEventListener('change',_markSettingsDirty,{once:false});}
// Language preference — populate from LOCALES bundle
const langSel=$('settingsLanguage');
if(langSel){
langSel.innerHTML='';
if(typeof LOCALES!=='undefined'){
for(const [code,bundle] of Object.entries(LOCALES)){
const opt=document.createElement('option');
opt.value=code;opt.textContent=bundle._label||code;
langSel.appendChild(opt);
}
}
langSel.value=resolvedLanguage;
langSel.addEventListener('change',_markSettingsDirty,{once:false});
}
const showUsageCb=$('settingsShowTokenUsage');
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_markSettingsDirty,{once:false});}
const showCliCb=$('settingsShowCliSessions');
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});}
const syncCb=$('settingsSyncInsights');
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
const updateCb=$('settingsCheckUpdates');
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});}
const soundCb=$('settingsSoundEnabled');
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});}
const notifCb=$('settingsNotificationsEnabled');
if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_markSettingsDirty,{once:false});}
const bubbleCb=$('settingsBubbleLayout');
if(bubbleCb){bubbleCb.checked=!!settings.bubble_layout;bubbleCb.addEventListener('change',_markSettingsDirty,{once:false});}
// Bot name
const botNameField=$('settingsBotName');
if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});}
// Password field: always blank (we don't send hash back)
const pwField=$('settingsPassword');
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}
// Show auth buttons only when auth is active
try{
const authStatus=await api('/api/auth/status');
_setSettingsAuthButtonsVisible(!!authStatus.auth_enabled);
}catch(e){}
_syncHermesPanelSessionActions();
switchSettingsSection(_settingsSection);
if(_settingsSection==='gateways') loadGatewaysPanel();
}catch(e){
showToast(t('settings_load_failed')+e.message);
}
}
function _setSettingsAuthButtonsVisible(active){
const signOutBtn=$('btnSignOut');
if(signOutBtn) signOutBtn.style.display=active?'':'none';
const disableBtn=$('btnDisableAuth');
if(disableBtn) disableBtn.style.display=active?'':'none';
}
function _applySavedSettingsUi(saved, body, opts){
const {sendKey,showTokenUsage,showCliSessions,theme,language}=opts;
window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
window._showCliSessions=showCliSessions;
window._soundEnabled=body.sound_enabled;
window._notificationsEnabled=body.notifications_enabled;
window._botName=body.bot_name||'Hermes';
document.body.classList.toggle('bubble-layout', !!body.bubble_layout);
if(typeof applyBotName==='function') applyBotName();
if(typeof setLocale==='function') setLocale(language);
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
if(typeof startGatewaySSE==='function'){
if(showCliSessions) startGatewaySSE();
else if(typeof stopGatewaySSE==='function') stopGatewaySSE();
}
_setSettingsAuthButtonsVisible(!!saved.auth_enabled);
_settingsDirty=false;
_settingsThemeOnOpen=theme;
const bar=$('settingsUnsavedBar');
if(bar) bar.style.display='none';
renderMessages();
if(typeof syncTopbar==='function') syncTopbar();
if(typeof renderSessionList==='function') renderSessionList();
}
async function saveSettings(andClose){
const model=($('settingsModel')||{}).value;
const sendKey=($('settingsSendKey')||{}).value;
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
const pw=($('settingsPassword')||{}).value;
const theme=($('settingsTheme')||{}).value||'dark';
const language=($('settingsLanguage')||{}).value||'en';
const body={};
if(model) body.default_model=model;
if(sendKey) body.send_key=sendKey;
body.theme=theme;
body.language=language;
body.show_token_usage=showTokenUsage;
body.show_cli_sessions=showCliSessions;
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
body.bubble_layout=!!($('settingsBubbleLayout')||{}).checked;
document.body.classList.toggle('bubble-layout', body.bubble_layout);
const botName=(($('settingsBotName')||{}).value||'').trim();
body.bot_name=botName||'Hermes';
// Password: only act if the field has content; blank = leave auth unchanged
if(pw && pw.trim()){
try{
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language});
showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
_hideSettingsPanel();
return;
}catch(e){showToast(t('settings_save_failed')+e.message);return;}
}
try{
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language});
showToast(t('settings_saved'));
_hideSettingsPanel();
}catch(e){
showToast(t('settings_save_failed')+e.message);
}
}
async function signOut(){
try{
await api('/api/auth/logout',{method:'POST',body:'{}'});
window.location.href='/login';
}catch(e){
showToast(t('sign_out_failed')+e.message);
}
}
async function disableAuth(){
const _disAuth=await showConfirmDialog({title:t('disable_auth_confirm_title'),message:t('disable_auth_confirm_message'),confirmLabel:t('disable'),danger:true,focusCancel:true});
if(!_disAuth) return;
try{
await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})});
showToast(t('auth_disabled'));
// Hide both auth buttons since auth is now off
const disableBtn=$('btnDisableAuth');
if(disableBtn) disableBtn.style.display='none';
const signOutBtn=$('btnSignOut');
if(signOutBtn) signOutBtn.style.display='none';
}catch(e){
showToast(t('disable_auth_failed')+e.message);
}
}
// Close settings on overlay click (not panel click) -- with unsaved-changes check
document.addEventListener('click',e=>{
const overlay=$('settingsOverlay');
if(overlay&&e.target===overlay) _closeSettingsPanel();
});
// ── Cron completion alerts ────────────────────────────────────────────────────
let _cronPollSince=Date.now()/1000; // track from page load
let _cronPollTimer=null;
let _cronUnreadCount=0;
function startCronPolling(){
if(_cronPollTimer) return;
_cronPollTimer=setInterval(async()=>{
if(document.hidden) return; // don't poll when tab is in background
try{
const data=await api(`/api/crons/recent?since=${_cronPollSince}`);
if(data.completions&&data.completions.length>0){
for(const c of data.completions){
showToast(t('cron_completion_status', c.name, c.status==='error' ? t('status_failed') : t('status_completed')),4000);
_cronPollSince=Math.max(_cronPollSince,c.completed_at);
}
_cronUnreadCount+=data.completions.length;
updateCronBadge();
}
}catch(e){}
},30000);
}
function updateCronBadge(){
const tab=document.querySelector('.nav-tab[data-panel="tasks"]');
if(!tab) return;
let badge=tab.querySelector('.cron-badge');
if(_cronUnreadCount>0){
if(!badge){
badge=document.createElement('span');
badge.className='cron-badge';
tab.style.position='relative';
tab.appendChild(badge);
}
badge.textContent=_cronUnreadCount>9?'9+':_cronUnreadCount;
badge.style.display='';
}else if(badge){
badge.style.display='none';
}
}
// Clear cron badge when Tasks tab is opened, load Projects panel
const _origSwitchPanel=switchPanel;
switchPanel=async function(name){
if(name==='tasks'){_cronUnreadCount=0;updateCronBadge();}
if(name==='projects'){loadProjectsPanel();}
return _origSwitchPanel(name);
};
// Start polling on page load
startCronPolling();
// ── Background agent error tracking ──────────────────────────────────────────
const _backgroundErrors=[]; // {session_id, title, message, ts}
function trackBackgroundError(sessionId, title, message){
// Only track if user is NOT currently viewing this session
if(S.session&&S.session.session_id===sessionId) return;
_backgroundErrors.push({session_id:sessionId, title:title||t('untitled'), message, ts:Date.now()});
showErrorBanner();
}
function showErrorBanner(){
let banner=$('bgErrorBanner');
if(!banner){
banner=document.createElement('div');
banner.id='bgErrorBanner';
banner.className='bg-error-banner';
const msgs=document.querySelector('.messages');
if(msgs) msgs.parentNode.insertBefore(banner,msgs);
else document.body.appendChild(banner);
}
const latest=_backgroundErrors[0]; // FIFO: show oldest (first) error
if(!latest){banner.style.display='none';return;}
const count=_backgroundErrors.length;
const msg=count>1?t('bg_error_multi',count):t('bg_error_single',latest.title);
banner.innerHTML=`\u26a0 ${esc(msg)}`;
banner.style.display='';
}
function navigateToErrorSession(){
const latest=_backgroundErrors.shift(); // FIFO: show oldest error first
if(latest){
loadSession(latest.session_id);renderSessionList();
}
if(_backgroundErrors.length===0) dismissErrorBanner();
else showErrorBanner();
}
function dismissErrorBanner(){
_backgroundErrors.length=0;
const banner=$('bgErrorBanner');
if(banner) banner.style.display='none';
}
// ── Mission Control panel ──
let _mcInterval = null;
async function loadMissionControl() {
clearInterval(_mcInterval);
await refreshMC();
_mcInterval = setInterval(refreshMC, 15000);
}
async function refreshMC() {
const [statusRes, tasksRes, feedRes, prioritiesRes] = await Promise.all([
api('/api/mc/status'),
api('/api/mc/tasks'),
api('/api/mc/feed?limit=10'),
api('/api/mc/priorities'),
]);
const status = statusRes;
const tasks = tasksRes.tasks || [];
const feed = feedRes.feed || [];
const priorities = prioritiesRes.priorities || [];
// ── Health Badge ──
const h = status.dashboard_health || 'empty';
const healthColors = { healthy: '#4caf50', active: '#ff9800', warning: '#ff5722', empty: '#9e9e9e', ok: '#4caf50' };
const healthLabels = { healthy: 'Healthy', active: 'Active', warning: 'Warning', empty: 'No Data', ok: 'OK' };
const $hb = $('mcHealthBadge');
$hb.textContent = healthLabels[h] || 'Unknown';
$hb.style.color = healthColors[h] || '#9e9e9e';
$hb.style.background = (healthColors[h] || '#9e9e9e') + '22';
// ── Stats Cards ──
const totalTasks = tasks.length;
const doneTasks = tasks.filter(t => t.status === 'done').length;
const progressPct = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0;
$('mcTasksCount').textContent = `${doneTasks}/${totalTasks}`;
$('mcTasksLabel').textContent = `${doneTasks} done · ${totalTasks - doneTasks} open`;
$('mcPrioritiesCount').textContent = priorities.length;
$('mcPrioritiesLabel').textContent = `${priorities.filter(p => p.done).length} done`;
// ── Progress Bar ──
$('mcProgressBar').style.width = progressPct + '%';
$('mcProgressPct').textContent = progressPct + '%';
// ── Priority Filters ──
const priorityMap = {};
priorities.forEach(p => { priorityMap[p.id] = p; });
const priorityEmoji = { 1: '🔴', 2: '🟠', 3: '🟡', 4: '🟢' };
const pfEl = $('mcPriorityFilters');
pfEl.innerHTML = [
``,
...priorities.map(p =>
``
)
].join('');
// ── Tasks List ──
const listEl = $('mcTasksList');
const activeFilter = window._mcPriorityFilter || 'all';
const filteredTasks = activeFilter === 'all' ? tasks : tasks.filter(t => t.priority === activeFilter);
if (filteredTasks.length === 0) {
listEl.innerHTML = 'No tasks yet.
Add one above ↑
';
} else {
const statusMeta = {
backlog: { icon: '○', color: 'var(--muted)', label: 'Backlog' },
progress: { icon: '◐', color: '#ff9800', label: 'In Progress' },
done: { icon: '●', color: '#4caf50', label: 'Done' }
};
listEl.innerHTML = filteredTasks.map(t => {
const meta = statusMeta[t.status] || statusMeta.backlog;
const p = priorityMap[t.priority] || { name: 'Unknown', color: '#808080' };
const emoji = priorityEmoji[t.priority] || '•';
return `
${meta.icon}
${esc(t.title)}
${meta.label}
${emoji} ${esc(p.name)}
`;
}).join('');
}
// ── Feed ──
const feedEl = $('mcFeed');
if (feed.length === 0) {
feedEl.innerHTML = 'No recent activity
';
} else {
feedEl.innerHTML = feed.map(f => {
const d = new Date(f.timestamp);
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return `
${esc(f.event)}
${time}
`;
}).join('');
}
}
function toggleMCTask(id, currentStatus) {
const next = currentStatus === 'done' ? 'backlog' : currentStatus === 'backlog' ? 'progress' : 'done';
updateMCTask(id, next);
}
function filterMCTasks(priorityId) {
window._mcPriorityFilter = priorityId;
refreshMC();
}
async function createMCTask() {
const title = $('mcNewTaskTitle').value.trim();
if (!title) return;
const priority = parseInt($('mcNewTaskPriority').value);
const status = $('mcNewTaskStatus').value;
$('mcNewTaskTitle').value = '';
await api('/api/mc/task/create', { title, priority, status });
await refreshMC();
}
async function updateMCTask(id, status) {
await api('/api/mc/task/update', { id, status });
await refreshMC();
}
// ── Priority Management ──
async function createMCPriority() {
const name = prompt('Priority name:');
if (!name) return;
const color = prompt('Color (hex, e.g. #ff0000):', '#808080');
if (!color) return;
await api('/api/mc/priority/create', { name, color });
await refreshMC();
}
async function deleteMCPriority(id) {
if (!confirm('Delete this priority?')) return;
await api('/api/mc/priority/delete', { id });
await refreshMC();
}
// ── Agents Panel (Rose + Tier-2) ─────────────────────────────────────────────
let _agentsInterval = null;
let _selectedAgent = null;
let _agentTab = 'overview'; // current tab in detail overlay
const STATUS_COLORS = { active: '#4caf50', idle: '#ff9800', offline: '#9e9e9e' };
const STATUS_LABELS = { active: 'Active', idle: 'Idle', offline: 'Offline' };
function _relTime(ts) {
if (!ts) return 'N/A';
try {
const d = new Date(ts);
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return 'Just now';
if (diff < 3600) return `${Math.floor(diff/60)}m ago`;
if (diff < 86400) return `${Math.floor(diff/3600)}h ago`;
return d.toLocaleDateString();
} catch { return ts; }
}
async function loadAgentsPanel() {
clearInterval(_agentsInterval);
await refreshAgents();
_agentsInterval = setInterval(refreshAgents, 15000);
}
async function refreshAgents() {
try {
const data = await api('/api/agents');
renderAgentsList(data.agents || []);
} catch(e) {
const box = $('agentsList');
if (box) box.innerHTML = `Error: ${esc(e.message)}
`;
}
}
function renderAgentsList(agents) {
const box = $('agentsList');
if (!box) return;
const html = agents.map(a => {
const color = STATUS_COLORS[a.status] || STATUS_COLORS.offline;
const label = STATUS_LABELS[a.status] || 'Offline';
const tierBadge = a.tier === 'orchestrator'
? '🌹 Tier-1'
: 'Tier-2';
const inboxBadge = a.inbox_count > 0
? `${a.inbox_count}`
: '';
const disabled = a.disabled ? 'opacity:0.5;' : '';
return `
${a.emoji}
${esc(a.name)}
${esc(a.domain)}
${label}
${tierBadge}
${_relTime(a.last_activity)}
${inboxBadge}
›
`;
}).join('');
box.innerHTML = html;
}
async function openAgentDetail(agentId) {
_selectedAgent = agentId;
_agentTab = 'overview';
// Highlight card
document.querySelectorAll('.agent-card').forEach(el => el.classList.remove('selected'));
const cards = document.querySelectorAll('.agent-card');
const agentsData = await api('/api/agents');
const idx = agentsData.agents.findIndex(a => a.id === agentId);
if (cards[idx]) cards[idx].classList.add('selected');
const box = $('agentInbox');
// Fetch full agent data
let agent;
try {
agent = await api(`/api/agents/${agentId}`);
} catch(e) {
box.innerHTML = `Error loading agent: ${esc(e.message)}
`;
box.style.display = 'block';
return;
}
const color = STATUS_COLORS[agent.status] || STATUS_COLORS.offline;
const tierBadge = agent.tier === 'orchestrator'
? '🌹 Tier-1'
: 'Tier-2';
const lastAct = agent.last_activity ? new Date(agent.last_activity).toLocaleString() : 'N/A';
const canEdit = agentId !== 'rose';
box.innerHTML = `
${STATUS_LABELS[agent.status] || 'Offline'}
${agent.pid ? `PID ${agent.pid}` : ''}
Last active: ${esc(lastAct)}
${agent.default_model ? `
Model: ${esc(agent.default_model)}
` : ''}
${agentId !== 'rose' ? `
Agent ${agent.disabled ? 'disabled' : 'enabled'}
` : ''}
`;
box.style.display = 'block';
// Load first tab content
await switchAgentTab('overview');
}
async function switchAgentTab(tab) {
_agentTab = tab;
const agentId = _selectedAgent;
if (!agentId) return;
// Update tab buttons
document.querySelectorAll('.agent-tab').forEach((el, i) => {
const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors', 'chat', 'tasks', 'bus', 'usage', 'topology'];
el.classList.toggle('active', tabs[i] === tab);
});
const content = $('agentTabContent');
switch(tab) {
case 'overview':
await loadAgentOverview(agentId, content);
break;
case 'soul':
await loadAgentSoul(agentId, content);
break;
case 'memory':
await loadAgentMemory(agentId, content);
break;
case 'inbox':
await loadAgentInboxTab(agentId, content);
break;
case 'activity':
await loadAgentActivity(agentId, content);
break;
case 'errors':
await loadAgentErrors(agentId, content);
break;
case 'chat':
await loadAgentChatHistory(agentId, content);
break;
case 'tasks':
await loadAgentTasks(agentId, content);
break;
case 'bus':
await loadAgentBus(agentId, content);
break;
case 'usage':
await loadAgentUsage(agentId, content);
break;
case 'topology':
await loadAgentTopology(agentId, content);
break;
}
}
async function loadAgentOverview(agentId, content) {
try {
const agent = await api(`/api/agents/${agentId}`);
const color = STATUS_COLORS[agent.status] || STATUS_COLORS.offline;
content.innerHTML = `
Status
${STATUS_LABELS[agent.status] || 'Offline'}
Domain
${esc(agent.domain)}
Tier
${agent.tier === 'orchestrator' ? '🌹 Orchestrator (Tier-1)' : 'Tier-2 Domain Agent'}
Last Active
${agent.last_activity ? new Date(agent.last_activity).toLocaleString() : 'N/A'}
${agent.default_model ? `
Model
${esc(agent.default_model)}
` : ''}
${agent.inbox_count > 0 ? `
Inbox
${agent.inbox_count} unread messages
` : ''}
${agent.pid ? `
Process
PID ${agent.pid}
` : ''}
`;
// Fetch health metrics in parallel
try {
const health = await api(`/api/agents/${agentId}/health`);
if (!health.error && health.status !== 'offline') {
const memBar = health.memory_mb > 0 ? `` : '';
const uptime = health.uptime_seconds > 0 ? _formatUptime(health.uptime_seconds) : 'N/A';
content.innerHTML += `
System Health
CPU
${health.cpu_percent}%
Memory
${health.memory_mb} MB ${memBar}
Threads
${health.threads}
`;
}
} catch(e) {}
} catch(e) {
content.innerHTML = `Error: ${esc(e.message)}
`;
}
}
function _formatUptime(seconds) {
if (!seconds || seconds <= 0) return 'N/A';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
async function loadAgentSoul(agentId, content) {
const canEdit = agentId !== 'rose';
try {
const agent = await api(`/api/agents/${agentId}`);
const soul = agent.soul || '';
if (!soul) {
content.innerHTML = ``;
return;
}
content.innerHTML = `
${canEdit ? `
` : ''}
${renderMarkdown(soul)}
`;
} catch(e) {
content.innerHTML = `Error: ${esc(e.message)}
`;
}
}
async function loadAgentMemory(agentId, content) {
const canEdit = agentId !== 'rose';
// Fetch memory.md + render search bar
try {
const agent = await api(`/api/agents/${agentId}`);
const memory = agent.memory || '';
content.innerHTML = `
${canEdit ? `
` : ''}
${memory ? renderMarkdown(memory) : '
No memory.md found
'}
`;
} catch(e) {
content.innerHTML = `Error: ${esc(e.message)}
`;
}
}
async function searchAgentMemory(agentId) {
const q = document.getElementById('memSearchInput').value.trim();
const resultsBox = document.getElementById('memSearchResults');
if (!q) return;
resultsBox.style.display = '';
resultsBox.innerHTML = `Searching...
`;
try {
const endpoint = agentId === 'rose'
? `/api/agents/memory/search?q=${encodeURIComponent(q)}`
: `/api/agents/${agentId}/memory/search?q=${encodeURIComponent(q)}`;
const data = await api(endpoint);
const results = data.results || [];
if (!results.length) {
resultsBox.innerHTML = `No results for "${esc(q)}"
`;
return;
}
resultsBox.innerHTML = `
${results.length} result${results.length!==1?'s':''}
${results.map(r => `
${esc(r.topic)}
${(r.confidence*100).toFixed(0)}%
${r.agent ? 'Agent: '+esc(r.agent)+' · ' : ''}Topic: ${esc(r.topic)}
${esc((r.content||'').slice(0,200))}
`).join('')}
`;
} catch(e) {
resultsBox.innerHTML = `Error: ${esc(e.message)}
`;
}
}
async function loadAgentInboxTab(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/inbox`);
const messages = data.messages || [];
const agentName = data.agent_name || agentId;
if (messages.length === 0) {
content.innerHTML = `
📭
No messages in inbox
Messages from other agents appear here
`;
return;
}
const msgsHtml = messages.map(m => {
const ts = m.timestamp ? new Date(m.timestamp).toLocaleString() : '';
const isUnread = m.status !== 'read';
const typeColor = m.type === 'request' ? '#ff9800' : '#4caf50';
const typeLabel = m.type === 'request' ? '📨 REQUEST' : '✅ REPLY';
return `
${esc(m.subject || '(no subject)')}
${esc(String(m.content || '').slice(0,200))}
${isUnread ? `` : ''}
`;
}).join('');
content.innerHTML = `
${msgsHtml}
`;
} catch(e) {
content.innerHTML = `Error: ${esc(e.message)}
`;
}
}
async function loadAgentActivity(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/activity`);
const events = data.events || [];
if (events.length === 0) {
content.innerHTML = `
📋
No activity recorded yet
Events like messages, tasks and updates appear here
`;
return;
}
const EVENT_COLORS = {
'agent_started': '#4caf50',
'agent_stopped': '#9e9e9e',
'message_sent': '#2196f3',
'message_received': '#00bcd4',
'task_started': '#ff9800',
'task_completed': '#4caf50',
'task_failed': '#f44336',
'soul_updated': '#9c27b0',
'memory_updated': '#3f51b5',
'chat_started': '#ff9800',
'chat_ended': '#795548',
'health_check': '#4caf50',
'config_updated': '#607d8b',
'error': '#f44336',
};
const EVENT_ICONS = {
'agent_started': '🟢',
'agent_stopped': '⚫',
'message_sent': '📤',
'message_received': '📥',
'task_started': '▶️',
'task_completed': '✅',
'task_failed': '❌',
'soul_updated': '✏️',
'memory_updated': '🧠',
'chat_started': '💬',
'chat_ended': '💬',
'health_check': '❤️',
'config_updated': '⚙️',
'error': '⚠️',
};
const rows = events.map(e => {
const color = EVENT_COLORS[e.type] || '#9e9e9e';
const icon = EVENT_ICONS[e.type] || '•';
const ts = e.timestamp ? new Date(e.timestamp).toLocaleString() : 'N/A';
const rel = e.timestamp ? _relTime(e.timestamp) : '';
const details = e.details ? `${esc(e.details)}` : '';
return `
${icon}
${esc(e.type)}
${details}
${esc(ts)} · ${rel}
`;
}).join('');
content.innerHTML = `${rows}
`;
} catch(e) {
content.innerHTML = `Error: ${esc(e.message)}
`;
}
}
async function loadAgentErrors(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/errors`);
const errors = data.errors || [];
if (errors.length === 0) {
content.innerHTML = `
✅
No errors recorded
All good — this agent has no logged errors
`;
return;
}
const rows = errors.map(e => {
const ts = e.timestamp ? new Date(e.timestamp).toLocaleString() : 'N/A';
const rel = e.timestamp ? _relTime(e.timestamp) : '';
return `
⚠️
${esc(e.details || 'Unknown error')}
${esc(ts)} · ${rel}
`;
}).join('');
content.innerHTML = `
⚠️ ${errors.length} error${errors.length !== 1 ? 's' : ''} total
${rows}
`;
} catch(e) {
content.innerHTML = `Error: ${esc(e.message)}
`;
}
}
function toggleInboxMsg(el) {
el.classList.toggle('expanded');
}
async function loadAgentChatHistory(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/chat-history`);
const sessions = data.sessions || [];
if (sessions.length === 0) {
content.innerHTML = `
💬
No chat history yet
Your conversations with ${agentId} appear here
`;
return;
}
const rows = sessions.map(s => {
const created = s.created_at ? new Date(s.created_at).toLocaleString() : 'N/A';
const rel = s.created_at ? _relTime(s.created_at) : '';
const model = s.model || 'unknown';
return `
${esc(s.title)}
${created} · ${rel}
${esc(model)}
${s.message_count} msgs
`;
}).join('');
content.innerHTML = `${rows}
`;
} catch(e) {
content.innerHTML = `Error: ${esc(e.message)}
`;
}
}
function openAgentChatSession(agentId, sessionId) {
// Switch to the chat panel and load this session
closeAgentDetail();
if (typeof switchToChatPanel === 'function') switchToChatPanel();
if (typeof loadSession === 'function') loadSession(sessionId);
showToast(`Loading chat session...`);
}
async function loadAgentTasks(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/tasks`);
const tasks = data.tasks || [];
if (tasks.length === 0) {
content.innerHTML = `
📋
No tasks in queue
Tasks will appear here when agents are working on something
`;
return;
}
const TASK_STATUS_COLORS = { 'running': '#4caf50', 'queued': '#ff9800', 'completed': '#9e9e9e', 'failed': '#f44336' };
const rows = tasks.map(t => {
const color = TASK_STATUS_COLORS[t.status] || '#9e9e9e';
const ts = t.created_at ? new Date(t.created_at).toLocaleString() : '';
return `
${esc(t.description || 'Task')}
${esc(ts)} · ${esc(t.status)}
`;
}).join('');
content.innerHTML = `
${tasks.length} task${tasks.length !== 1 ? 's' : ''}
${rows}
`;
} catch(e) {
content.innerHTML = `Error: ${esc(e.message)}
`;
}
}
async function loadAgentBus(agentId, content) {
try {
const data = await api(`/api/agents/message-bus`);
const bus = data.bus || {};
// Collect all messages across agents, filter to those involving agentId
const allMsgs = [];
for (const [aId, aData] of Object.entries(bus)) {
const msgs = aData.messages || [];
for (const m of msgs) {
if (m.from === agentId || m.to === agentId) {
allMsgs.push({ ...m, _agent: aId });
}
}
}
// Sort newest first
allMsgs.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
if (allMsgs.length === 0) {
content.innerHTML = `
🚌
No messages in the bus
Messages between agents appear here
`;
return;
}
const rows = allMsgs.map(m => {
const ts = m.timestamp ? new Date(m.timestamp).toLocaleString() : 'N/A';
const rel = m.timestamp ? _relTime(m.timestamp) : '';
const isOutgoing = m.from === agentId;
const dirIcon = isOutgoing ? '📤' : '📥';
const dirLabel = isOutgoing ? `→ ${m.to}` : `← ${m.from}`;
const typeColor = m.type === 'request' ? '#ff9800' : '#4caf50';
return `
${dirIcon}
${esc(m.type || '').toUpperCase()}
${dirLabel}
${esc(ts)} · ${rel}
${esc(m.subject || '(no subject)')}
${esc(String(m.content || '').slice(0, 120))}
`;
}).join('');
content.innerHTML = `
${rows}
`;
} catch(e) {
content.innerHTML = `Error: ${esc(e.message)}
`;
}
}
async function sendBusMessage(toAgent) {
const subject = document.getElementById('busSubject').value.trim();
const content = document.getElementById('busContent').value.trim();
if (!subject && !content) {
showToast('Please enter a subject or message');
return;
}
try {
const r = await api(`/api/agents/${toAgent}/bus-message`, {
method: 'POST',
body: JSON.stringify({ from_agent: 'rose', subject, content }),
});
if (!r.ok) throw new Error(r.error || 'Send failed');
showToast('Message sent via bus');
document.getElementById('busSubject').value = '';
document.getElementById('busContent').value = '';
// Refresh
await switchAgentTab('bus');
} catch(e) {
showToast('Error: ' + e.message);
}
}
async function loadAgentTopology(agentId, content) {
const agents = [
{ id: 'rose', name: 'rose', emoji: '🌹', tier: 'orchestrator', x: 0, y: 0 },
{ id: 'lotus', name: 'lotus', emoji: '🪷', tier: 'tier2', x: 0, y: -1 },
{ id: 'forget-me-not', name: 'forget-me-not', emoji: '🌼', tier: 'tier2', x: 0.7, y: -0.7 },
{ id: 'sunflower', name: 'sunflower', emoji: '🌻', tier: 'tier2', x: -1, y: 0 },
{ id: 'iris', name: 'iris', emoji: '⚜️', tier: 'tier2', x: 0, y: 1 },
{ id: 'ivy', name: 'ivy', emoji: '🌿', tier: 'tier2', x: -0.7, y: 0.7 },
{ id: 'dandelion', name: 'dandelion', emoji: '🛡️', tier: 'tier2', x: 0.7, y: 0.7 },
{ id: 'root', name: 'root', emoji: '🌳', tier: 'tier2', x: 0, y: 0.7 }
];
const connections = [
{ from: 'rose', to: 'lotus' },
{ from: 'rose', to: 'forget-me-not' },
{ from: 'rose', to: 'sunflower' },
{ from: 'rose', to: 'iris' },
{ from: 'rose', to: 'ivy' },
{ from: 'rose', to: 'dandelion' },
{ from: 'rose', to: 'root' },
{ from: 'lotus', to: 'forget-me-not' },
{ from: 'sunflower', to: 'lotus' },
{ from: 'iris', to: 'rose' },
{ from: 'ivy', to: 'rose' },
{ from: 'dandelion', to: 'rose' },
{ from: 'root', to: 'rose' }
];
const scale = 80;
let svg = '';
content.innerHTML = '' + svg + '
';
}
async function loadAgentUsage(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/usage`);
if (data.error) {
content.innerHTML = `${esc(data.error)}
`;
return;
}
const fmt = (n) => n.toLocaleString();
const fmtCost = (c) => '$' + c.toFixed(4);
const historyRows = (data.history || []).slice(0, 14).map(h => `
| ${esc(h.date)} |
${fmt(h.total_tokens || 0)} |
${fmt(h.prompt_tokens || 0)} |
${fmt(h.completion_tokens || 0)} |
${fmtCost(h.cost_usd || 0)} |
`).join('');
content.innerHTML = `
Today
${fmt(data.today.tokens)}
${fmtCost(data.today.cost)}
This Week
${fmt(data.week.tokens)}
${fmtCost(data.week.cost)}
This Month
${fmt(data.month.tokens)}
${fmtCost(data.month.cost)}
${historyRows ? `
Recent History
| Date |
Total |
Prompt |
Completion |
Cost |
${historyRows}
` : `
📊
No usage data recorded yet
Token usage will appear here once recorded
`}
`;
} catch(e) {
content.innerHTML = `Failed to load usage: ${esc(e.message)}
`;
}
}
// Edit handlers
function editAgentSoul(agentId) {
document.getElementById('soulView').style.display = 'none';
document.getElementById('soulEdit').style.display = 'block';
}
function cancelEditSoul(agentId) {
document.getElementById('soulView').style.display = 'block';
document.getElementById('soulEdit').style.display = 'none';
}
async function saveAgentSoul(agentId) {
const content = document.getElementById('soulEditArea').value;
const errEl = document.getElementById('soulEditError');
errEl.style.display = 'none';
try {
const r = await api(`/api/agents/${agentId}/soul`, { method: 'PUT', body: JSON.stringify({ content }) });
if (!r.ok) throw new Error(r.error || 'Save failed');
showToast('soul.md saved');
await switchAgentTab('soul');
} catch(e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
}
function editAgentMemory(agentId) {
document.getElementById('memoryView').style.display = 'none';
document.getElementById('memoryEdit').style.display = 'block';
}
function cancelEditMemory(agentId) {
document.getElementById('memoryView').style.display = 'block';
document.getElementById('memoryEdit').style.display = 'none';
}
async function saveAgentMemory(agentId) {
const content = document.getElementById('memoryEditArea').value;
const errEl = document.getElementById('memoryEditError');
errEl.style.display = 'none';
try {
const r = await api(`/api/agents/${agentId}/memory`, { method: 'PUT', body: JSON.stringify({ content }) });
if (!r.ok) throw new Error(r.error || 'Save failed');
showToast('memory.md saved');
await switchAgentTab('memory');
} catch(e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
}
async function sendToAgent(agentId) {
const subject = document.getElementById('msgToAgentSubject').value.trim();
const body = document.getElementById('msgToAgentBody').value.trim();
const errEl = document.getElementById('sendToAgentError');
errEl.style.display = 'none';
if (!body) { errEl.textContent = 'Message body is required'; errEl.style.display = 'block'; return; }
try {
const r = await api(`/api/agents/${agentId}/message`, {
method: 'POST',
body: JSON.stringify({ from: 'rose', type: 'request', subject, content: body }),
});
if (!r.ok) throw new Error(r.error || 'Send failed');
showToast('Message sent');
document.getElementById('msgToAgentSubject').value = '';
document.getElementById('msgToAgentBody').value = '';
await switchAgentTab('inbox');
} catch(e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
}
async function ackMsg(agentId, msgId) {
try {
await api(`/api/agents/${agentId}/ack/${msgId}`, { method: 'POST' });
await switchAgentTab('inbox');
await refreshAgents();
} catch(e) {
showToast('Ack failed: ' + e.message);
}
}
async function toggleAgentEnabled(agentId, enable) {
try {
const r = await api(`/api/agents/${agentId}/${enable ? 'enable' : 'disable'}`, { method: 'POST' });
if (!r.ok) throw new Error(r.error || 'Toggle failed');
showToast(`Agent ${enable ? 'enabled' : 'disabled'}`);
await openAgentDetail(agentId);
await refreshAgents();
} catch(e) {
showToast('Error: ' + e.message);
}
}
function chatWithAgent(agentId) {
localStorage.setItem('hermes.chat_agent', agentId);
closeAgentInbox();
switchPanel('chat');
}
function closeAgentInbox() {
$('agentInbox').style.display = 'none';
_selectedAgent = null;
document.querySelectorAll('.agent-card').forEach(el => el.classList.remove('selected'));
}
// Simple markdown renderer (bold, italic, code, headers, lists, linebreaks)
function renderMarkdown(text) {
if (!text) return '';
return esc(text)
.replace(/<(\/?)(pre|code|strong|b|em|i|li|ul|ol|h[1-6]|br|p)>/gi, '<$1$2>')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`(.+?)`/g, '$1')
.replace(/^### (.+)$/gm, '$1')
.replace(/^## (.+)$/gm, '$1
')
.replace(/^# (.+)$/gm, '$1
')
.replace(/^- (.+)$/gm, '
$1')
.replace(/^(\d+)\. (.+)$/gm, '$2')
.replace(/\n/g, '
');
}
// ── Logs Panel ────────────────────────────────────────────────────────────────
let _currentLogFile = null;
let _currentLogContent = '';
let _currentLogLevel = 'all';
let _currentLogSearch = '';
let _logAutoRefreshInterval = null;
async function loadLogsPanel() {
const el = $('logsFileList');
if (!el) return;
el.innerHTML = 'Loading...
';
try {
const data = await api('/api/logs');
if (!data.logs) return;
el.innerHTML = '';
data.logs.forEach(log => {
const item = document.createElement('div');
item.className = 'logs-sidebar-item' + (log.missing ? ' missing' : '') + (_currentLogFile === log.name ? ' active' : '');
item.onclick = () => { if (!log.missing) selectLog(log.name); };
item.innerHTML = `
`;
el.appendChild(item);
});
} catch(e) {
el.innerHTML = 'Failed to load logs.
';
}
}
async function selectLog(name) {
_currentLogFile = name;
_currentLogLevel = 'all';
_currentLogSearch = '';
$('logsSearchInput').value = '';
$('logsFileName').textContent = name;
// Show toolbar controls
$('logsSearchInput').style.display = '';
$('logsLevelBtns').style.display = '';
$('logsAutoRefreshLabel').style.display = '';
$('btnRefreshLog').style.display = '';
$('logsFooter').style.display = '';
$('logsContent').innerHTML = 'Loading...
';
// Reset level buttons
document.querySelectorAll('.log-level-btn').forEach(b => b.classList.toggle('active', b.dataset.level === 'all'));
try {
const data = await api('/api/logs/' + encodeURIComponent(name));
_currentLogContent = data.content || '';
_applyLogFilter();
} catch(e) {
$('logsContent').innerHTML = 'Failed to load log.
';
}
// Update active state in list
document.querySelectorAll('.logs-sidebar-item').forEach(el => {
el.classList.toggle('active', el.querySelector('.logs-sidebar-name').textContent === name);
});
// Stop auto-refresh if running for different log
if (_logAutoRefreshInterval) {
clearInterval(_logAutoRefreshInterval);
_logAutoRefreshInterval = null;
}
}
function _applyLogFilter() {
let content = _currentLogContent;
let lines = content.split('\n');
// Filter by level
if (_currentLogLevel !== 'all') {
const levelMap = { ERROR: ['ERROR', 'CRITICAL', 'FATAL'], WARN: ['WARNING', 'WARN'], INFO: ['INFO', 'DEBUG', 'TRACE'] };
const allowed = levelMap[_currentLogLevel] || [_currentLogLevel];
lines = lines.filter(line => allowed.some(l => line.toUpperCase().includes(l)));
}
// Filter by search
const searchLower = _currentLogSearch ? _currentLogSearch.toLowerCase() : '';
if (searchLower) {
lines = lines.filter(line => line.toLowerCase().includes(searchLower));
}
// Render with highlighting
const total = _currentLogContent.split('\n').length;
const shown = lines.length;
$('logsMatchCount').textContent = searchLower || _currentLogLevel !== 'all'
? `${shown} of ${total} lines shown`
: `${total} lines`;
if (lines.length === 0) {
$('logsContent').innerHTML = '(no matches)';
return;
}
// Escape first, then highlight search terms
const highlighted = lines.map((line, i) => {
const escaped = esc(line) || ' ';
if (searchLower) {
// Highlight all occurrences of search term (case-insensitive, preserves case in display)
const regex = new RegExp(escRegex(_currentLogSearch), 'gi');
return `${escaped.replace(regex, m => `${m}`)}`;
}
return `${escaped}`;
}).join('\n');
$('logsContent').innerHTML = highlighted;
// Auto-scroll to first match when searching
if (searchLower) {
const pre = $('logsContent');
const first = pre.querySelector('.log-highlight');
if (first) {
first.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else {
// Normal: scroll to bottom (newest)
const pre = $('logsContent');
pre.scrollTop = pre.scrollHeight;
}
}
// Escape special regex characters
function escRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function filterLogContent() {
_currentLogSearch = $('logsSearchInput').value;
_applyLogFilter();
}
function setLogLevel(level) {
_currentLogLevel = level;
document.querySelectorAll('.log-level-btn').forEach(b => b.classList.toggle('active', b.dataset.level === level));
_applyLogFilter();
}
function toggleLogAutoRefresh() {
const enabled = $('logsAutoRefresh').checked;
if (enabled) {
if (!_logAutoRefreshInterval) {
_logAutoRefreshInterval = setInterval(() => refreshLog(), 5000);
}
} else {
if (_logAutoRefreshInterval) {
clearInterval(_logAutoRefreshInterval);
_logAutoRefreshInterval = null;
}
}
}
async function refreshLog() {
if (!_currentLogFile) return;
try {
const data = await api('/api/logs/' + encodeURIComponent(_currentLogFile));
_currentLogContent = data.content || '';
_applyLogFilter();
// Auto-scroll to bottom if near bottom
const pre = $('logsContent');
if (pre.scrollHeight - pre.scrollTop - pre.clientHeight < 100) {
pre.scrollTop = pre.scrollHeight;
}
} catch(e) {
// Silent fail on auto-refresh
}
}
async function refreshLogManual() {
if (!_currentLogFile) return;
try {
const data = await api('/api/logs/' + encodeURIComponent(_currentLogFile));
_currentLogContent = data.content || '';
_applyLogFilter();
const pre = $('logsContent');
pre.scrollTop = pre.scrollHeight;
showToast('Log refreshed');
} catch(e) {
showToast('Refresh failed');
}
}
function _formatDate(ts) {
if (!ts) return '';
const d = new Date(ts * 1000);
return d.toLocaleDateString(undefined, {month:'short', day:'numeric'}) + ' ' +
d.toLocaleTimeString(undefined, {hour:'2-digit', minute:'2-digit'});
}
// ── Heartbeats panel ─────────────────────────────────────────────────────────
let _heartbeatsCache = null;
async function loadHeartbeatsPanel() {
const panel = $('heartbeatsPanelContent');
if (!panel) return;
try {
_heartbeatsCache = await api('/api/heartbeats');
renderHeartbeatsList(_heartbeatsCache);
} catch (e) {
panel.innerHTML = 'Failed to load heartbeats: ' + e.message + '
';
}
}
function renderHeartbeatsList(data) {
const panel = $('heartbeatsPanelContent');
if (!panel) return;
const hb = data.heartbeats || [];
const total = data.total || 0;
const pending = data.pending_due_count || 0;
const manager = data._manager || {};
let html = `
Active Heartbeats (${hb.length})
${hb.length === 0 ? '
No heartbeats yet
' : ''}
`;
for (const h of hb) {
const isRecurring = h.recurring;
const iterationInfo = isRecurring && h.max_iterations
? ` (${h.iteration_count || 0}/${h.max_iterations})`
: isRecurring ? ' (∞)' : '';
const statusClass = h.status === 'pending' ? 'hb-item-pending' : 'hb-item-' + h.status;
const dueInfo = h.status === 'pending' ? `→ ${_nextIn(h.trigger_at)}` : h.status;
html += `
${h.action}
${h.source}
${h.priority || 'n'}
${dueInfo}
${iterationInfo}
${_escapeHtml(h.instruction || h.user_message || '')}
`;
}
html += '';
// Also attach manager info from separate call
panel.innerHTML = html;
// Toggle recurring fields
const recurringCheck = document.getElementById('hb-recurring');
const intervalInput = document.getElementById('hb-interval');
const maxiterInput = document.getElementById('hb-maxiter');
const agentSelect = document.getElementById('hb-agent');
const actionSelect = document.getElementById('hb-action');
if (recurringCheck) recurringCheck.addEventListener('change', () => {
const show = recurringCheck.checked;
intervalInput.style.display = show ? '' : 'none';
maxiterInput.style.display = show ? '' : 'none';
});
if (actionSelect) actionSelect.addEventListener('change', () => {
agentSelect.style.display = actionSelect.value === 'delegate' ? '' : 'none';
});
}
function _escapeHtml(s) {
return String(s).replace(/&/g,'&').replace(//g,'>');
}
async function hbQuickReminder() {
const instr = prompt('Reminder text for Rose:');
if (!instr) return;
await api('/api/heartbeats', {
method: 'POST',
body: JSON.stringify({ source: 'webui', action: 'rose_continue', instruction: instr, minutes: 5, priority: 'high' })
});
showToast('Reminder set');
await loadHeartbeatsPanel();
}
async function hbQuickDailyCheck() {
await api('/api/heartbeats', {
method: 'POST',
body: JSON.stringify({
source: 'webui', action: 'rose_continue',
instruction: 'Daily calendar check — any meetings or conflicts today?',
minutes: 0, recurring: true, interval_minutes: 1440, max_iterations: 30
})
});
showToast('Daily check created (30 days)');
await loadHeartbeatsPanel();
}
async function hbQuickMemoryCheck() {
await api('/api/heartbeats', {
method: 'POST',
body: JSON.stringify({
source: 'webui', action: 'rose_continue',
instruction: 'Memory Health Check — run /search memory health',
minutes: 60, recurring: true, interval_minutes: 60, max_iterations: 999
})
});
showToast('Hourly memory check created');
await loadHeartbeatsPanel();
}
async function hbCreate() {
const instruction = document.getElementById('hb-instruction')?.value.trim();
const action = document.getElementById('hb-action')?.value;
const minutes = parseInt(document.getElementById('hb-minutes')?.value) || 5;
const priority = document.getElementById('hb-priority')?.value || undefined;
const recurring = document.getElementById('hb-recurring')?.checked;
const interval_minutes = parseInt(document.getElementById('hb-interval')?.value) || minutes;
const max_iterations = parseInt(document.getElementById('hb-maxiter')?.value) || undefined;
const target_agent = action === 'delegate' ? document.getElementById('hb-agent')?.value : undefined;
if (!instruction) {
showToast('Please enter an instruction');
return;
}
const body = { source: 'webui', action, instruction, minutes, recurring };
if (priority) body.priority = priority;
if (recurring) {
body.interval_minutes = interval_minutes;
if (max_iterations) body.max_iterations = max_iterations;
}
if (target_agent) body.target_agent = target_agent;
try {
await api('/api/heartbeats', {
method: 'POST',
body: JSON.stringify(body)
});
showToast('Heartbeat created');
document.getElementById('hb-instruction').value = '';
await loadHeartbeatsPanel();
} catch (e) {
showToast('Error: ' + e.message);
}
}
async function hbCancel(id) {
if (!confirm('Cancel heartbeat ' + id + '?')) return;
try {
await api('/api/heartbeats/' + id, { method: 'DELETE' });
showToast('Cancelled');
await loadHeartbeatsPanel();
} catch (e) {
showToast('Error: ' + e.message);
}
}
function _nextIn(dateStr) {
if (!dateStr) return '—';
const diff = new Date(dateStr).getTime() - Date.now();
if (diff <= 0) return 'now';
const mins = Math.floor(diff / 60000);
if (mins < 60) return `in ${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `in ${hours}h ${mins % 60}m`;
return `in ${Math.floor(hours / 24)}d`;
}
// ── Projects panel ──────────────────────────────────────────────────────────
let projectsState = {
projects: [],
allTasks: [],
filter: { type: 'all', priority: null },
expanded: false
};
async function loadProjectsPanel() {
try {
const [projectsRes, tasksRes, statsRes] = await Promise.all([
api('/api/projects'),
api('/api/projects/tasks'),
api('/api/projects/stats')
]);
// Merge projects from projects/ folder (via our api) with tasks
projectsState.projects = projectsRes.projects || [];
projectsState.allTasks = tasksRes.tasks || [];
renderProjectsStats(statsRes);
renderProjectsList();
renderKanban();
// Set "All" filter button as active
document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === 'all');
});
} catch(e) {
console.error('loadProjectsPanel:', e);
}
}
function renderProjectsStats(stats) {
const el = $('projectsStats');
if (el) {
el.innerHTML = `
📋 ${stats.total_tasks || 0}
✅ ${stats.done || 0}
🎯 ${stats.today_completed || 0}
`;
}
// Render expanded stats grid
const grid = $('statsGrid');
if (grid) {
grid.style.display = 'flex';
$('statTotal').textContent = stats.total_tasks || 0;
$('statDone').textContent = stats.done || 0;
$('statToday').textContent = stats.today_completed || 0;
$('statStreak').textContent = stats.streak || 0;
if (stats.overdue > 0) {
$('statOverdueCard').style.display = 'flex';
$('statOverdue').textContent = stats.overdue;
} else {
$('statOverdueCard').style.display = 'none';
}
}
}
function renderProjectsList() {
// Projects sidebar
const list = $('projectsList');
if (list) {
list.innerHTML = projectsState.projects.map(p => `
${p.name || p.id}
${(p.tasks || []).length}
`).join('');
}
// Daily tasks
const dailyList = $('dailyTasksList');
const dailyTasks = projectsState.allTasks.filter(t => t.task_type === 'daily');
if (dailyList) {
dailyList.innerHTML = dailyTasks.length ? dailyTasks.map(t => renderTaskItem(t)).join('')
: 'No daily tasks
';
}
// Recurring tasks
const recList = $('recurringTasksList');
const recTasks = projectsState.allTasks.filter(t => t.task_type === 'recurring');
if (recList) {
recList.innerHTML = recTasks.length ? recTasks.map(t => renderTaskItem(t)).join('')
: 'No recurring tasks
';
}
}
function renderTaskItem(t) {
const prioColors = { p1: '🔴', p2: '🟡', p3: '🟢' };
const prio = prioColors[t.priority] || '⚪';
const done = t.status === 'done' ? '✓' : '○';
const doneCls = t.status === 'done' ? 'done' : '';
const due = t.due ? `📅 ${t.due}` : '';
const tags = (t.tags || []).map(tag => `${esc(tag)}`).join('');
const ownerBadgeMap = { 'user': '👤', 'rose': '🌹', 'agent:lotus': '🪷', 'agent:sunflower': '🌻', 'agent:iris': '⚜️', 'agent:ivy': '🌿', 'agent:dandelion': '🛡️', 'agent:root': '🌳' };
const ownerBadge = ownerBadgeMap[t.owner] || '';
return `
${done}
${esc(t.title)}
${ownerBadge}
${prio}
${due}
${tags}
`;
}
function renderKanban() {
const tasks = getFilteredTasks();
const cols = {
todo: tasks.filter(t => t.status === 'todo'),
in_progress: tasks.filter(t => t.status === 'in_progress'),
review: tasks.filter(t => t.status === 'review'),
done: tasks.filter(t => t.status === 'done')
};
for (const [colId, colTasks] of Object.entries(cols)) {
const el = $(`kanban${colId.split('_').map((p, i) => i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)).join('')}Content`);
if (el) {
el.innerHTML = colTasks.map(t => renderKanbanCard(t)).join('');
}
}
}
function renderKanbanCard(t) {
const prioColors = { p1: 'var(--accent)', p2: 'var(--warning)', p3: 'var(--success)' };
const borderColor = prioColors[t.priority] || 'var(--border)';
const projectLabel = t.project_name ? `${esc(t.project_name)}` : '';
const today = new Date().toISOString().slice(0, 10);
const isOverdue = t.due && t.due < today && t.status !== 'done';
const due = t.due ? `📅 ${esc(t.due)}` : '';
const tags = (t.tags || []).slice(0, 2).map(tag => `${esc(tag)}`).join('');
const prioLabel = { p1: '🔴', p2: '🟡', p3: '🟢' }[t.priority] || '';
const ownerBadge = t.owner ? `${esc(t.owner)}` : '';
return `
${projectLabel}
${ownerBadge}
`;
}
function getFilteredTasks() {
let tasks = projectsState.allTasks;
const f = projectsState.filter;
if (f.type === 'daily') {
tasks = projectsState.allTasks.filter(t => t.task_type === 'daily');
} else if (f.type === 'recurring') {
tasks = projectsState.allTasks.filter(t => t.task_type === 'recurring');
} else if (f.type === 'project') {
tasks = projectsState.allTasks.filter(t => t.task_type === 'project');
}
// 'all' shows everything (no filter)
if (f.priority) {
tasks = tasks.filter(t => t.priority === f.priority);
}
return tasks;
}
function filterTasks(type) {
if (['all', 'project', 'daily', 'recurring'].includes(type)) {
projectsState.filter.type = type;
document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => {
if (['all', 'project', 'daily', 'recurring'].includes(btn.dataset.filter)) {
btn.classList.toggle('active', btn.dataset.filter === type);
}
});
} else if (['p1', 'p2', 'p3'].includes(type)) {
if (projectsState.filter.priority === type) {
projectsState.filter.priority = null;
} else {
projectsState.filter.priority = type;
}
document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => {
if (['p1', 'p2', 'p3'].includes(btn.dataset.filter)) {
btn.classList.toggle('active', btn.dataset.filter === projectsState.filter.priority);
}
});
}
renderKanban();
}
async function quickAddTask() {
const input = $('quickAddInput');
const typeSelect = $('quickAddType');
const dueInput = $('quickAddDue');
const title = input.value.trim();
if (!title) return;
const task = {
title,
task_type: typeSelect.value,
status: 'todo',
priority: 'p2',
project_id: projectsState.projects[0]?.id || null,
due: dueInput.value || null,
tags: []
};
try {
await api('/api/projects/tasks', { method: 'POST', body: task });
input.value = '';
dueInput.value = '';
await loadProjectsPanel();
} catch(e) {
console.error('quickAddTask:', e);
showToast('Error: ' + e.message);
}
}
async function toggleTaskStatus(taskId) {
const task = projectsState.allTasks.find(t => t.id === taskId);
if (!task) return;
const newStatus = task.status === 'done' ? 'todo' : 'done';
const completed = newStatus === 'done' ? new Date().toISOString() : null;
try {
await api(`/api/projects/tasks/${taskId}`, {
method: 'PUT',
body: { status: newStatus, completed }
});
await loadProjectsPanel();
} catch(e) {
console.error('toggleTaskStatus:', e);
}
}
let _currentTaskId = null;
let _originalTaskStatus = null;
function openTaskModal(taskId) {
const task = projectsState.allTasks.find(t => t.id === taskId);
if (!task) return;
_currentTaskId = taskId;
_originalTaskStatus = task.status;
const modal = $('taskDetailModal');
const title = $('taskModalTitle');
const inputTitle = $('taskModalInputTitle');
const selStatus = $('taskModalSelectStatus');
const selPrio = $('taskModalSelectPrio');
const inputDue = $('taskModalInputDue');
const selOwner = $('taskModalSelectOwner');
const inputTags = $('taskModalInputTags');
const meta = $('taskModalMeta');
const ownerLabels = {
'user': '👤 Sabo', 'rose': '🌹 Rose',
'agent:lotus': '🪷 Lotus', 'agent:sunflower': '🌻 Sunflower',
'agent:iris': '⚜️ Iris', 'agent:ivy': '🌿 Ivy',
'agent:dandelion': '🛡️ Dandelion', 'agent:root': '🌳 Root'
};
title.textContent = task.title;
inputTitle.value = task.title;
selStatus.value = task.status || 'todo';
selPrio.value = task.priority || 'p2';
inputDue.value = task.due || '';
selOwner.value = task.owner || 'user';
inputTags.value = (task.tags || []).join(', ');
const created = task.created ? new Date(task.created).toLocaleDateString() : '—';
const updated = task.updated ? new Date(task.updated).toLocaleDateString() : '—';
meta.textContent = `ID: ${task.id} | Created: ${created} | Updated: ${updated}`;
modal.style.display = 'flex';
}
function closeTaskModal() {
$('taskDetailModal').style.display = 'none';
_currentTaskId = null;
}
async function saveTaskFromModal() {
if (!_currentTaskId) return;
const title = $('taskModalInputTitle').value.trim();
if (!title) {
showToast('Title cannot be empty');
return;
}
const updates = {
title,
status: $('taskModalSelectStatus').value,
priority: $('taskModalSelectPrio').value,
due: $('taskModalInputDue').value || null,
owner: $('taskModalSelectOwner').value,
tags: $('taskModalInputTags').value.split(',').map(t => t.trim()).filter(Boolean)
};
if (updates.status === 'done' && _originalTaskStatus !== 'done') {
updates.completed = new Date().toISOString();
}
try {
await api(`/api/projects/tasks/${_currentTaskId}`, {
method: 'PUT',
body: updates
});
closeTaskModal();
await loadProjectsPanel();
showToast('Task saved');
} catch(e) {
showToast('Error saving task: ' + e.message);
}
}
async function deleteTaskFromModal() {
if (!_currentTaskId) return;
const task = projectsState.allTasks.find(t => t.id === _currentTaskId);
if (!confirm(`Delete task "${task?.title}"? This cannot be undone.`)) return;
try {
await api(`/api/projects/tasks/${_currentTaskId}`, { method: 'DELETE' });
closeTaskModal();
await loadProjectsPanel();
showToast('Task deleted');
} catch(e) {
showToast('Error deleting task: ' + e.message);
}
}
// Legacy: Kanban card click opens modal
function openTaskDetail(taskId) {
openTaskModal(taskId);
}
function expandPanel(panelName) {
const panelEl = $(`panel${panelName.charAt(0).toUpperCase() + panelName.slice(1)}`);
if (!panelEl) return;
const isExpanded = panelEl.classList.contains('panel-expanded');
panelEl.classList.toggle('panel-expanded', !isExpanded);
const btn = $(`btnExpand${panelName.charAt(0).toUpperCase() + panelName.slice(1)}`);
if (btn) {
if (!isExpanded) {
btn.innerHTML = ``;
btn.title = 'Collapse';
} else {
btn.innerHTML = ``;
btn.title = 'Expand';
}
}
if (!isExpanded) {
document.addEventListener('keydown', _escHandler = (e) => {
if (e.key === 'Escape') {
expandPanel(panelName);
document.removeEventListener('keydown', _escHandler);
}
});
}
}
// Event wiring
// ── Kanban Drag & Drop ────────────────────────────────────────────────────────
let draggedTaskId = null;
function onKanbanDragStart(e, taskId) {
draggedTaskId = taskId;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', taskId);
e.target.style.opacity = '0.5';
}
function onKanbanDragEnd(e) {
e.target.style.opacity = '1';
draggedTaskId = null;
}
function onKanbanDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
async function onKanbanDrop(e, newStatus) {
e.preventDefault();
if (!draggedTaskId) return;
const taskId = draggedTaskId;
draggedTaskId = null;
try {
await api(`/api/projects/tasks/${taskId}`, {
method: 'PUT',
body: { status: newStatus }
});
await loadProjectsPanel();
} catch(err) {
showToast('Move failed: ' + err.message);
}
}
// ── Task Edit Modal ────────────────────────────────────────────────────────────
let editingTaskId = null;
let selectedProjectColor = '#6366f1';
function openTaskEditModal(taskId) {
const task = (projectsState.tasks || projectsState.allTasks || []).find(t => t.id === taskId);
if (!task) return;
editingTaskId = taskId;
$('editTaskTitle').value = task.title || '';
$('editTaskType').value = task.task_type || 'project';
$('editTaskPriority').value = task.priority || 'p2';
$('editTaskDue').value = task.due || '';
// Populate project dropdown
const projSelect = $('editTaskProject');
projSelect.innerHTML = '';
projectsState.projects.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
if (p.id === task.project_id) opt.selected = true;
projSelect.appendChild(opt);
});
// Recurring options visibility
$('editTaskRecurringOpts').style.display = task.task_type === 'recurring' ? 'block' : 'none';
if (task.recurring) {
$('editRecInterval').value = task.recurring.interval || 1;
$('editRecUnit').value = task.recurring.unit || 'days';
}
$('taskEditModal').style.display = 'flex';
}
function closeTaskEditModal() {
$('taskEditModal').style.display = 'none';
editingTaskId = null;
}
function onEditTypeChange() {
$('editTaskRecurringOpts').style.display = $('editTaskType').value === 'recurring' ? 'block' : 'none';
}
async function saveTaskEdit() {
if (!editingTaskId) return;
const updated = {
title: $('editTaskTitle').value.trim(),
task_type: $('editTaskType').value,
priority: $('editTaskPriority').value,
project_id: $('editTaskProject').value || null,
due: $('editTaskDue').value || null,
};
if ($('editTaskType').value === 'recurring') {
updated.recurring = {
interval: parseInt($('editRecInterval').value) || 1,
unit: $('editRecUnit').value
};
}
try {
await api(`/api/projects/tasks/${editingTaskId}`, { method: 'PUT', body: updated });
closeTaskEditModal();
await loadProjectsPanel();
} catch(e) {
showToast('Error saving: ' + e.message);
}
}
async function deleteTask(taskId) {
if (!confirm('Delete this task?')) return;
try {
await api(`/api/projects/tasks/${taskId}`, { method: 'DELETE' });
await loadProjectsPanel();
} catch(e) {
showToast('Error deleting: ' + e.message);
}
}
// ── Project Edit Modal ─────────────────────────────────────────────────────────
let editingProjectId = null;
function openProjectEditModal(projectId) {
const project = projectsState.projects.find(p => p.id === projectId);
if (!project) return;
editingProjectId = projectId;
$('editProjectName').value = project.name || '';
selectedProjectColor = project.color || '#6366f1';
document.querySelectorAll('.color-dot').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.color === selectedProjectColor);
});
$('projectEditModal').style.display = 'flex';
}
function closeProjectEditModal() {
$('projectEditModal').style.display = 'none';
editingProjectId = null;
}
function selectProjectColor(btn) {
selectedProjectColor = btn.dataset.color;
document.querySelectorAll('.color-dot').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
}
async function saveProjectEdit() {
if (!editingProjectId) return;
const updated = { name: $('editProjectName').value.trim(), color: selectedProjectColor };
try {
await api(`/api/projects/projects/${editingProjectId}`, { method: 'PUT', body: updated });
closeProjectEditModal();
await loadProjectsPanel();
} catch(e) {
showToast('Error saving: ' + e.message);
}
}
async function deleteProject(projectId) {
if (!confirm('Delete this project and all its tasks?')) return;
try {
await api(`/api/projects/projects/${projectId}`, { method: 'DELETE' });
await loadProjectsPanel();
} catch(e) {
showToast('Error deleting: ' + e.message);
}
}