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 === 'workspaces') await loadWorkspacesPanel(); if (name === 'todos') loadTodos(); } // ── Cron panel ── async function loadCrons() { const box = $('cronList'); try { const data = await api('/api/crons'); if (!data.jobs || !data.jobs.length) { box.innerHTML = '
No scheduled jobs found.
'; 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 nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'N/A'; const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : 'never'; item.innerHTML = `
${esc(job.name)} ${statusLabel}
🕑 ${esc(job.schedule_display || job.schedule?.expression || '')}  |  Next: ${esc(nextRun)}  |  Last: ${esc(lastRun)}
${esc((job.prompt||'').slice(0,300))}${(job.prompt||'').length>300?'…':''}
${statusLabel==='paused' ? `` : ``}
Last output
Loading…
`; box.appendChild(item); // Eagerly load last output for visible items loadCronOutput(job.id); } } catch(e) { box.innerHTML = `
Error: ${esc(e.message)}
`; } } 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'; $('cronFormName').focus(); } } 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='Schedule is required (e.g. "0 9 * * *" or "every 1h")';errEl.style.display='';return;} if(!prompt){errEl.textContent='Prompt is required';errEl.style.display='';return;} try{ await api('/api/crons/create',{method:'POST',body:JSON.stringify({name:name||undefined,schedule,prompt,deliver})}); toggleCronForm(); showToast('Job created ✓'); await loadCrons(); }catch(e){ errEl.textContent='Error: '+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 = '(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 = 'All runs'; return; } if (btn) btn.textContent = 'Loading…'; try { const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20`); if (!data.outputs || !data.outputs.length) { histEl.innerHTML = '
(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)}
`; }).join(''); } histEl.style.display = ''; if (btn) btn.textContent = 'Hide runs'; } catch(e) { if (btn) btn.textContent = '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('Job triggered ✓'); setTimeout(() => loadCronOutput(id), 5000); } catch(e) { showToast('Run failed: ' + e.message, 4000); } } async function cronPause(id) { try { await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id})}); showToast('Job paused'); await loadCrons(); } catch(e) { showToast('Pause failed: ' + e.message, 4000); } } async function cronResume(id) { try { await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})}); showToast('Job resumed ✓'); await loadCrons(); } catch(e) { showToast('Resume failed: ' + 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 = 'Schedule is required'; errEl.style.display = ''; return; } if (!prompt) { errEl.textContent = 'Prompt is 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('Job updated ✓'); await loadCrons(); } catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; } } async function cronDelete(id) { if (!confirm('Delete this cron job? This cannot be undone.')) return; try { await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})}); showToast('Job deleted'); await loadCrons(); } catch(e) { showToast('Delete failed: ' + e.message, 4000); } } function loadTodos() { const panel = $('todoPanel'); if (!panel) return; // Parse the most recent todo state from message history let todos = []; for (let i = S.messages.length - 1; i >= 0; i--) { const m = S.messages[i]; if (m && m.role === 'tool') { try { const d = JSON.parse(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)); if (d && Array.isArray(d.todos) && d.todos.length) { todos = d.todos; break; } } catch(e) {} } } if (!todos.length) { panel.innerHTML = '
No active task list in this session.
'; return; } const statusIcon = {pending:'○', in_progress:'◉', completed:'✓', cancelled:'✗'}; const statusColor = {pending:'var(--muted)', in_progress:'var(--blue)', completed:'rgba(100,200,100,.8)', cancelled:'rgba(200,100,100,.5)'}; panel.innerHTML = todos.map(t => `
${statusIcon[t.status]||'○'}
${esc(t.content)}
${esc(t.id)} · ${esc(t.status)}
`).join(''); } async function clearConversation() { if(!S.session) return; if(!confirm('Clear all messages in this conversation? This cannot be undone.')) 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('Conversation cleared'); } catch(e) { setStatus('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 = '
No skills match.
'; return; } for (const [cat, items] of Object.entries(cats).sort()) { const sec = document.createElement('div'); sec.className = 'skills-category'; sec.innerHTML = `
📁 ${esc(cat)} (${items.length})
`; 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); sec.appendChild(el); } box.appendChild(sec); } } 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'); $('previewMd').innerHTML = renderMd(data.content || '(no content)'); $('previewArea').classList.add('visible'); $('fileTree').style.display = 'none'; } catch(e) { setStatus('Could not load skill: ' + e.message); } } // ── 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 = 'Skill name is required'; errEl.style.display = ''; return; } if (!content.trim()) { errEl.textContent = 'Content is required'; errEl.style.display = ''; return; } try { await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})}); showToast(_editingSkillName ? 'Skill updated ✓' : 'Skill created ✓'); _skillsData = null; toggleSkillForm(); await loadSkills(); } catch(e) { errEl.textContent = 'Error: ' + 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 = 'memory (notes)'; $('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('Memory saved ✓'); closeMemoryEdit(); await loadMemory(true); } catch(e) { errEl.textContent = 'Error: ' + 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; } async function loadWorkspaceList(){ try{ const data = await api('/api/workspaces'); _workspaceList = data.workspaces || []; // Refresh sidebar display if we have a current session if(S.session && S.session.workspace) { const sidebarName=$('sidebarWsName'); const sidebarPath=$('sidebarWsPath'); if(sidebarName) sidebarName.textContent=getWorkspaceFriendlyName(S.session.workspace); if(sidebarPath) sidebarPath.textContent=S.session.workspace; } return data; }catch(e){ return {workspaces:[], last:''}; } } function renderWorkspaceDropdown(workspaces, currentWs){ const dd = $('wsDropdown'); 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=async()=>{ closeWsDropdown(); if(!S.session||w.path===S.session.workspace)return; await api('/api/session/update',{method:'POST',body:JSON.stringify({ session_id:S.session.session_id, workspace:w.path, model:S.session.model })}); S.session.workspace=w.path; syncTopbar(); await loadDir('.'); showToast(`Switched to ${w.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='ws-opt ws-manage'; mgmt.innerHTML='⚙ Manage workspaces'; mgmt.onclick=()=>{closeWsDropdown();switchPanel('workspaces');}; dd.appendChild(mgmt); } function toggleWsDropdown(){ const dd=$('wsDropdown'); if(!dd)return; const open=dd.classList.contains('open'); if(open){closeWsDropdown();} else{ loadWorkspaceList().then(data=>{ renderWorkspaceDropdown(data.workspaces, S.session?S.session.workspace:''); dd.classList.add('open'); }); } } function closeWsDropdown(){ const dd=$('wsDropdown'); if(dd)dd.classList.remove('open'); } document.addEventListener('click',e=>{ if(!e.target.closest('#wsChipWrap'))closeWsDropdown(); }); 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='Paths are validated as existing directories before saving.'; 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('Workspace added'); }catch(e){setStatus('Add failed: '+e.message);} } async function removeWorkspace(path){ if(!confirm(`Remove workspace "${path}"?`))return; try{ const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})}); _workspaceList=data.workspaces; renderWorkspacesPanel(data.workspaces); showToast('Workspace removed'); }catch(e){setStatus('Remove failed: '+e.message);} } async function switchToWorkspace(path,name){ if(!S.session)return; try{ 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(`Switched to ${name}`); }catch(e){setStatus('Switch 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() : ''; panel.innerHTML = `
🧠 My Notes ${fmtTime(data.memory_mtime)}
${data.memory ? `
${renderMd(data.memory)}
` : '
No notes yet.
'}
👤 User Profile ${fmtTime(data.user_mtime)}
${data.user ? `
${renderMd(data.user)}
` : '
No profile yet.
'}
`; } catch(e) { panel.innerHTML = `
Error: ${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 ─────────────────────────────────────────────────────────── function toggleSettings(){ const overlay=$('settingsOverlay'); if(!overlay) return; if(overlay.style.display==='none'){ overlay.style.display=''; loadSettingsPanel(); } else { overlay.style.display='none'; } } async function loadSettingsPanel(){ try{ const settings=await api('/api/settings'); // 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||''; } // Populate workspace dropdown from /api/workspaces const wsSel=$('settingsWorkspace'); if(wsSel){ wsSel.innerHTML=''; try{ const wsData=await api('/api/workspaces'); for(const w of (wsData.workspaces||[])){ const opt=document.createElement('option'); opt.value=w.path;opt.textContent=w.name||w.path; wsSel.appendChild(opt); } }catch(e){} wsSel.value=settings.default_workspace||''; } }catch(e){ showToast('Failed to load settings: '+e.message); } } async function saveSettings(){ const model=($('settingsModel')||{}).value; const workspace=($('settingsWorkspace')||{}).value; const body={}; if(model) body.default_model=model; if(workspace) body.default_workspace=workspace; try{ await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); showToast('Settings saved'); toggleSettings(); }catch(e){ showToast('Save failed: '+e.message); } } // Close settings on overlay click (not panel click) document.addEventListener('click',e=>{ const overlay=$('settingsOverlay'); if(overlay&&e.target===overlay) toggleSettings(); }); // ── 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){ const icon=c.status==='error'?'\u274c':'\u2705'; showToast(`${icon} Cron "${c.name}" ${c.status==='error'?'failed':'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 const _origSwitchPanel=switchPanel; switchPanel=async function(name){ if(name==='tasks'){_cronUnreadCount=0;updateCronBadge();} 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||'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; banner.innerHTML=`\u26a0 ${count>1?count+' sessions have':'"'+esc(latest.title)+'" has'} encountered an error
`; banner.style.display=''; } function navigateToErrorSession(){ const latest=_backgroundErrors.shift(); // FIFO: show oldest error first if(latest){ loadSession(latest.session_id);renderSessionList(); } if(_backgroundErrors.length===0) dismissErrorBanner(); else showErrorBanner(); } function dismissErrorBanner(){ _backgroundErrors.length=0; const banner=$('bgErrorBanner'); if(banner) banner.style.display='none'; } // Event wiring