Files
odysseus/static/js/tasks.js
pewdiepie-archdaemon e5c99a5eee Odysseus v1.0
2026-05-31 23:58:26 +09:00

2574 lines
118 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tasks Module — scheduled recurring LLM prompts.
*/
import uiModule from './ui.js';
import markdownModule from './markdown.js';
import * as spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
const API_BASE = window.location.origin;
let _open = false;
let _tasksCascadeNext = false; // play the domino-in entrance on the next render
let _tasks = [];
let _tasksFetched = false; // first-fetch sentinel — `false` → show loading row instead of "No tasks yet"
let _escHandler = null;
let _viewingRuns = null; // task id when viewing run history
let _clockInterval = null;
const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
// ---- API ----
async function _fetchTasks() {
try {
const res = await fetch(`${API_BASE}/api/tasks?include_last_run=true`, { credentials: 'same-origin' });
const data = await res.json();
_tasks = data.tasks || [];
} catch (e) {
console.error('Failed to fetch tasks:', e);
_tasks = [];
}
_tasksFetched = true;
}
async function _runFirstOpenOnboarding() {
try {
const res = await fetch(`${API_BASE}/api/tasks/onboarding`, { credentials: 'same-origin' });
if (!res.ok) return;
const state = await res.json();
if (state.opened) return;
await fetch(`${API_BASE}/api/tasks/onboarding`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: false }),
});
} catch (e) {
console.warn('Tasks onboarding failed:', e);
}
}
async function _createTask(data) {
const res = await fetch(`${API_BASE}/api/tasks`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to create task');
return await res.json();
}
async function _updateTask(id, data) {
const res = await fetch(`${API_BASE}/api/tasks/${id}`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to update task');
return await res.json();
}
async function _deleteTask(id) {
const res = await fetch(`${API_BASE}/api/tasks/${id}`, {
method: 'DELETE', credentials: 'same-origin',
});
if (!res.ok) throw new Error('Failed to delete task');
}
function _taskCardById(id) {
const safe = (window.CSS && CSS.escape) ? CSS.escape(String(id)) : String(id).replace(/"/g, '\\"');
return document.querySelector(`.task-card[data-id="${safe}"]`);
}
function _animateTaskRemoval(ids) {
const cards = ids.map(_taskCardById).filter(Boolean);
if (!cards.length) return Promise.resolve();
for (const card of cards) {
card.style.maxHeight = `${Math.max(card.getBoundingClientRect().height, card.scrollHeight)}px`;
card.classList.add('memory-tidy-removing');
}
return new Promise(resolve => setTimeout(resolve, 520));
}
async function _pauseTask(id) {
const res = await fetch(`${API_BASE}/api/tasks/${id}/pause`, {
method: 'POST', credentials: 'same-origin',
});
if (!res.ok) throw new Error('Failed to pause task');
}
async function _resumeTask(id) {
const res = await fetch(`${API_BASE}/api/tasks/${id}/resume`, {
method: 'POST', credentials: 'same-origin',
});
if (!res.ok) throw new Error('Failed to resume task');
}
async function _runNow(id, force = false) {
const res = await fetch(`${API_BASE}/api/tasks/${id}/run${force ? '?force=true' : ''}`, {
method: 'POST', credentials: 'same-origin',
});
if (!res.ok) {
// Surface the backend's actual reason — 409 means "already running",
// 404 task missing, etc. Previously every error rendered as the same
// generic "Failed to trigger task", which hid the cause.
let msg = `Failed to trigger task (${res.status})`;
try {
const data = await res.json();
if (data && data.detail) msg = data.detail;
} catch (_) {}
if (res.status === 409) msg = 'Task is already running';
throw new Error(msg);
}
}
async function _fetchRuns(taskId, limit = 10) {
const res = await fetch(`${API_BASE}/api/tasks/${taskId}/runs?limit=${limit}`, {
credentials: 'same-origin',
});
if (!res.ok) return [];
const data = await res.json();
return data.runs || [];
}
let _outputTargets = null;
async function _fetchOutputTargets() {
if (_outputTargets) return _outputTargets;
try {
const res = await fetch(`${API_BASE}/api/tasks/meta/output-targets`, { credentials: 'same-origin' });
const data = await res.json();
_outputTargets = data.targets || [];
} catch (e) {
_outputTargets = [{ value: 'session', label: 'Session' }];
}
return _outputTargets;
}
let _builtinActions = null;
async function _fetchActions() {
if (_builtinActions) return _builtinActions;
try {
const res = await fetch(`${API_BASE}/api/tasks/meta/actions`, { credentials: 'same-origin' });
const data = await res.json();
_builtinActions = data.actions || [];
} catch (e) {
_builtinActions = [];
}
return _builtinActions;
}
let _urgentEmailSettings = null;
async function _fetchUrgentEmailSettings() {
if (_urgentEmailSettings) return _urgentEmailSettings;
try {
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
_urgentEmailSettings = await res.json();
} catch (e) {
_urgentEmailSettings = { urgent_email_prompt: '' };
}
return _urgentEmailSettings;
}
async function _saveUrgentEmailSettings(prompt) {
_urgentEmailSettings = {
...(_urgentEmailSettings || {}),
urgent_email_prompt: prompt || '',
};
await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
urgent_email_prompt: prompt || '',
}),
});
}
let _triggerEvents = null;
async function _fetchEvents() {
if (_triggerEvents) return _triggerEvents;
try {
const res = await fetch(`${API_BASE}/api/tasks/meta/events`, { credentials: 'same-origin' });
const data = await res.json();
_triggerEvents = data.events || [];
} catch (e) {
_triggerEvents = [];
}
return _triggerEvents;
}
// ---- Helpers ----
function _scheduleLabel(task) {
const tt = task.trigger_type || 'schedule';
if (tt === 'event') {
const evtName = (task.trigger_event || 'event').replace(/_/g, ' ');
const n = task.trigger_count || 1;
return `Every ${n} ${evtName}${n > 1 ? 's' : ''}`;
}
if (tt === 'webhook') return 'Webhook';
const t = task.scheduled_time || '00:00';
if (task.schedule === 'cron') return `Cron: ${task.cron_expression || '?'}`;
if (task.schedule === 'once') {
if (task.scheduled_date) {
const d = new Date(task.scheduled_date);
return `Once on ${d.toLocaleDateString()} at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
}
return 'Once';
}
const localTime = _utcTimeToLocal(t);
if (task.schedule === 'daily') return `Daily at ${localTime}`;
if (task.schedule === 'weekly') {
const day = DAYS_OF_WEEK[task.scheduled_day ?? 0];
return `Weekly on ${day} at ${localTime}`;
}
if (task.schedule === 'monthly') {
const d = task.scheduled_day ?? 1;
const suffix = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th';
return `Monthly on ${d}${suffix} at ${localTime}`;
}
return task.schedule || '—';
}
function _utcTimeToLocal(hhmm) {
const [h, m] = hhmm.split(':').map(Number);
const d = new Date();
d.setUTCHours(h, m, 0, 0);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function _localTimeToUtc(hhmm) {
const [h, m] = hhmm.split(':').map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
const uh = String(d.getUTCHours()).padStart(2, '0');
const um = String(d.getUTCMinutes()).padStart(2, '0');
return `${uh}:${um}`;
}
function _relativeTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const now = Date.now();
const diff = d - now;
const abs = Math.abs(diff);
const past = diff < 0;
if (abs < 60000) return past ? 'just now' : 'in a moment';
if (abs < 3600000) {
const m = Math.round(abs / 60000);
return past ? `${m}m ago` : `in ${m}m`;
}
if (abs < 86400000) {
const h = Math.round(abs / 3600000);
return past ? `${h}h ago` : `in ${h}h`;
}
const days = Math.round(abs / 86400000);
return past ? `${days}d ago` : `in ${days}d`;
}
// Absolute local time — unique per second. Used in run history so clustered
// runs don't all read as "just now".
function _absoluteTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
if (sameDay) return `${hh}:${mm}:${ss}`;
const mo = String(d.getMonth() + 1).padStart(2, '0');
const da = String(d.getDate()).padStart(2, '0');
return `${mo}/${da} ${hh}:${mm}`;
}
function _statusDot(status) {
const colors = { active: '#4caf50', paused: '#ff9800', completed: '#888', error: '#f44336' };
const c = colors[status] || '#888';
return `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${c};box-shadow:0 0 6px ${c}, 0 0 3px ${c};flex-shrink:0;position:relative;top:4px;"></span>`;
}
const _TASK_ICONS = {
// Chats
tidy_sessions: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
// Documents
tidy_documents: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>',
// Memory (brain)
consolidate_memory: '<path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/>',
// Research (magnifying glass)
tidy_research: '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
// Calendar
tidy_calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
// Email
summarize_emails: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
draft_email_replies: '<polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/>',
extract_email_events:'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><path d="M7 14h5"/><path d="M7 18h8"/>',
classify_events: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><path d="M8 15h.01M12 15h.01M16 15h.01"/>',
mark_email_boundaries:'<path d="M4 4h16v16H4z"/><path d="M4 9h16"/><path d="M9 4v16"/>',
learn_sender_signatures:'<path d="M20 6 9 17l-5-5"/><path d="M14 6h6v6"/>',
check_email_urgency: '<path d="M13.73 21a2 2 0 0 1-3.46 0"/><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/>',
// Skills
test_skills: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
audit_skills: '<path d="M9 11l3 3L22 4"/><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v15H6.5A2.5 2.5 0 0 0 4 19.5z"/>',
// Assistant
daily_brief: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
// Generic action fallback (gear)
_action_default: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
// LLM task fallback (chat bubble)
_llm_default: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
};
function _taskIcon(task) {
const action = task.action;
let path = _TASK_ICONS[action];
if (!path) {
path = task.task_type === 'action' ? _TASK_ICONS._action_default : _TASK_ICONS._llm_default;
}
return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.4;flex-shrink:0;position:relative;top:-4px;">${path}</svg>`;
}
// ---- Custom pickers ----
function _buildTimePicker(containerId, hour, minute) {
const wrap = document.getElementById(containerId);
if (!wrap) return;
wrap.innerHTML = '';
const hourSel = document.createElement('select');
hourSel.className = 'task-form-input task-time-select';
hourSel.id = containerId + '-hour';
for (let h = 0; h < 24; h++) {
const opt = document.createElement('option');
opt.value = h;
opt.textContent = String(h).padStart(2, '0');
if (h === hour) opt.selected = true;
hourSel.appendChild(opt);
}
const sep = document.createElement('span');
sep.className = 'task-time-sep';
sep.textContent = ':';
const minSel = document.createElement('select');
minSel.className = 'task-form-input task-time-select';
minSel.id = containerId + '-min';
for (let m = 0; m < 60; m += 5) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = String(m).padStart(2, '0');
if (m === minute || (m <= minute && m + 5 > minute)) opt.selected = true;
minSel.appendChild(opt);
}
wrap.appendChild(hourSel);
wrap.appendChild(sep);
wrap.appendChild(minSel);
}
function _getTimePickerValue(containerId) {
const h = parseInt(document.getElementById(containerId + '-hour')?.value ?? '9', 10);
const m = parseInt(document.getElementById(containerId + '-min')?.value ?? '0', 10);
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
}
function _buildDatePicker(containerId, initialDate) {
const wrap = document.getElementById(containerId);
if (!wrap) return;
wrap.innerHTML = '';
const now = initialDate || new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
// Year select
const yearSel = document.createElement('select');
yearSel.className = 'task-form-input task-date-select';
yearSel.id = containerId + '-year';
for (let y = year; y <= year + 2; y++) {
const opt = document.createElement('option');
opt.value = y;
opt.textContent = y;
if (y === year) opt.selected = true;
yearSel.appendChild(opt);
}
// Month select
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const monthSel = document.createElement('select');
monthSel.className = 'task-form-input task-date-select';
monthSel.id = containerId + '-month';
MONTHS.forEach((name, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = name;
if (i === month) opt.selected = true;
monthSel.appendChild(opt);
});
// Day select
const daySel = document.createElement('select');
daySel.className = 'task-form-input task-date-select';
daySel.id = containerId + '-day';
function populateDays() {
const y = parseInt(yearSel.value, 10);
const m = parseInt(monthSel.value, 10);
const daysInMonth = new Date(y, m + 1, 0).getDate();
const cur = parseInt(daySel.value, 10) || day;
daySel.innerHTML = '';
for (let d = 1; d <= daysInMonth; d++) {
const opt = document.createElement('option');
opt.value = d;
opt.textContent = String(d).padStart(2, '0');
if (d === Math.min(cur, daysInMonth)) opt.selected = true;
daySel.appendChild(opt);
}
}
populateDays();
yearSel.addEventListener('change', populateDays);
monthSel.addEventListener('change', populateDays);
wrap.appendChild(yearSel);
wrap.appendChild(monthSel);
wrap.appendChild(daySel);
}
function _getDatePickerValue(containerId) {
const y = parseInt(document.getElementById(containerId + '-year')?.value, 10);
const m = parseInt(document.getElementById(containerId + '-month')?.value, 10);
const d = parseInt(document.getElementById(containerId + '-day')?.value, 10);
return new Date(y, m, d);
}
// ---- Render ----
const _CATEGORY_MAP = {
// action -> category
tidy_sessions: 'Chats',
tidy_documents: 'Documents',
consolidate_memory: 'Memory',
tidy_research: 'Research',
tidy_calendar: 'Calendar',
classify_events: 'Calendar',
ping_events: 'Calendar',
extract_email_events: 'Calendar',
summarize_emails: 'Email',
draft_email_replies: 'Email',
mark_email_boundaries: 'Email',
learn_sender_signatures: 'Email',
check_email_urgency: 'Email',
daily_brief: 'Assistant',
test_skills: 'Skills',
audit_skills: 'Skills',
ssh_command: 'System',
run_script: 'System',
run_local: 'System',
};
const _CATEGORY_ORDER = ['Other', 'Calendar', 'Email', 'Chats', 'Documents', 'Memory', 'Research', 'Skills', 'Assistant', 'System'];
const _CATEGORY_ICONS = {
Calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
Email: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
Chats: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
Documents: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
Memory: '<path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/>',
Research: '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
Skills: '<path d="M9 11l3 3L22 4"/><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v15H6.5A2.5 2.5 0 0 0 4 19.5z"/>',
Assistant: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 18a5 5 0 0 1 10 0"/>',
System: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
Other: '<circle cx="12" cy="12" r="3"/>',
};
function _categoryFor(task) {
if (task.task_type === 'action' && task.action) {
return _CATEGORY_MAP[task.action] || 'Other';
}
// LLM tasks → Assistant if linked to a crew member, else Other
if (task.task_type === 'llm' || !task.task_type) {
return task.crew_member_id ? 'Assistant' : 'Other';
}
return 'Other';
}
// ---- Multi-select mode (mirrors the library's Select / bulk-bar) ----
function _taskEnterSelect() {
_taskSelectMode = true; _taskSelected.clear();
document.getElementById('tasks-bulk-bar')?.classList.remove('hidden');
const _sb = document.getElementById('tasks-select-btn');
if (_sb) { _sb.classList.add('active'); _sb.textContent = 'Cancel'; }
_taskUpdateBulkCount();
_renderList();
}
function _taskExitSelect() {
_taskSelectMode = false; _taskSelected.clear();
document.getElementById('tasks-bulk-bar')?.classList.add('hidden');
const _sb = document.getElementById('tasks-select-btn');
if (_sb) { _sb.classList.remove('active'); _sb.textContent = 'Select'; }
const sa = document.getElementById('tasks-select-all'); if (sa) sa.checked = false;
_renderList();
}
function _taskToggleSelectAll() {
const sa = document.getElementById('tasks-select-all');
if (!sa) return;
if (sa.checked) _tasks.forEach(t => _taskSelected.add(t.id)); else _taskSelected.clear();
_taskUpdateBulkCount();
_renderList();
}
function _taskUpdateBulkCount() {
const c = document.getElementById('tasks-selected-count');
if (c) c.textContent = `${_taskSelected.size} Selected`;
const del = document.getElementById('tasks-bulk-delete');
if (del) del.disabled = _taskSelected.size === 0;
}
async function _taskBulkDelete() {
const ids = [..._taskSelected];
if (!ids.length) return;
const ok = uiModule?.styledConfirm
? await uiModule.styledConfirm(`Delete ${ids.length} task${ids.length > 1 ? 's' : ''}? This cannot be undone.`, { confirmText: 'Delete', danger: true })
: confirm(`Delete ${ids.length} task(s)?`);
if (!ok) return;
const results = await Promise.allSettled(ids.map(id => _deleteTask(id)));
const deletedIds = ids.filter((_, i) => results[i].status === 'fulfilled');
await _animateTaskRemoval(deletedIds);
if (uiModule) uiModule.showToast(`Deleted ${deletedIds.length} task${deletedIds.length > 1 ? 's' : ''}`);
await _fetchTasks();
_taskExitSelect(); // clears selection + re-renders the fresh list
}
// Category filter chips (library-style tags) — solo-select: click one to
// show only that category, click it again to clear. Hidden if ≤1 category.
function _renderTaskChips() {
const bar = document.getElementById('tasks-filter-chips');
if (!bar) return;
const counts = {};
for (const t of _tasks) { const c = _categoryFor(t); counts[c] = (counts[c] || 0) + 1; }
const cats = Object.keys(counts).sort((a, b) => {
const ia = _CATEGORY_ORDER.indexOf(a), ib = _CATEGORY_ORDER.indexOf(b);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
});
if (_taskFilter && !counts[_taskFilter]) _taskFilter = null;
bar.innerHTML = '';
bar.style.display = cats.length > 1 ? 'flex' : 'none';
// Exact library style: .memory-cat-chip, an "all (N)" chip, then one per
// category with its count. Clicking "all" clears the filter.
const mkChip = (label, value, active) => {
const b = document.createElement('button');
b.className = 'memory-cat-chip' + (active ? ' active' : '');
b.textContent = label;
b.addEventListener('click', () => { _taskFilter = value; _renderList(); });
bar.appendChild(b);
};
mkChip(`all (${_tasks.length})`, null, !_taskFilter);
for (const c of cats) mkChip(`${c} (${counts[c]})`, c, _taskFilter === c);
}
function _renderList() {
const list = document.getElementById('tasks-list');
if (!list) return;
list.innerHTML = '';
// Sync the count badges (tab + header).
const _tabCount = document.getElementById('tasks-tab-count');
if (_tabCount) _tabCount.textContent = _tasks.length;
const _headCount = document.getElementById('tasks-head-count');
if (_headCount) _headCount.textContent = _tasks.length ? `${_tasks.length} task${_tasks.length !== 1 ? 's' : ''}` : '';
if (_tasks.length === 0) {
// Differentiate "still loading" from "really empty" so the first paint
// shows the app whirlpool (matching the document library) rather than a
// misleading "No tasks yet" message before the fetch completes.
if (!_tasksFetched) {
list.appendChild(spinnerModule.createLoadingRow('Loading…'));
} else {
list.innerHTML = '<div style="opacity:0.4;font-size:12px;text-align:center;padding:24px 0;">No tasks yet. Create one to get started.</div>';
}
return;
}
_renderTaskChips();
// Filter by the active category tag + search query, then flatten into one
// list (the tag chips replace the old per-category collapsible headers).
const q = _taskSearch.trim().toLowerCase();
const visible = _tasks.filter(t => {
if (_taskFilter && _categoryFor(t) !== _taskFilter) return false;
if (q && !(`${t.name} ${t.prompt || ''} ${t.action || ''}`.toLowerCase().includes(q))) return false;
return true;
});
const _statusRank = { active: 0, paused: 1, completed: 2 };
visible.sort((a, b) => {
if (_taskSort === 'name') return (a.name || '').localeCompare(b.name || '');
if (_taskSort === 'status') {
const sa = _statusRank[a.status] ?? 9, sb = _statusRank[b.status] ?? 9;
if (sa !== sb) return sa - sb;
return (a.name || '').localeCompare(b.name || '');
}
// 'recent' (default): category order, then name.
const ia = _CATEGORY_ORDER.indexOf(_categoryFor(a)), ib = _CATEGORY_ORDER.indexOf(_categoryFor(b));
if (ia !== ib) return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
return (a.name || '').localeCompare(b.name || '');
});
if (visible.length === 0) {
list.innerHTML = '<div style="opacity:0.4;font-size:12px;text-align:center;padding:24px 0;">No matching tasks.</div>';
return;
}
for (const task of visible) {
const card = document.createElement('div');
card.className = 'memory-item task-card' + (task.status === 'paused' ? ' task-paused' : '');
card.dataset.id = task.id;
// Title row: icon + name (left); paused badge, chevron (expanded only) +
// status dot (right). Click to expand.
const titleRow = document.createElement('div');
titleRow.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;';
const pausedBadge = task.status === 'paused'
? `<span class="task-paused-badge" title="Paused" style="position:relative;top:4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg> paused</span>`
: '';
const builtinBadge = task.is_builtin
? `<span class="task-builtin-badge${task.is_modified ? ' modified' : ''}" title="${task.is_modified ? 'Built-in task — edited from its default' : 'Built-in task'}">built-in${task.is_modified ? ' · edited' : ''}</span>`
: '';
titleRow.innerHTML = `${_taskIcon(task)}<span class="memory-item-title">${_esc(task.name)}</span>${builtinBadge}<span style="flex:1;"></span>${pausedBadge}${_statusDot(task.status)}`;
// ... menu button (hover to show)
const actionsWrap = document.createElement('div');
actionsWrap.className = 'memory-item-actions';
const menuBtn = document.createElement('button');
menuBtn.className = 'memory-item-btn';
menuBtn.title = 'Actions';
menuBtn.style.position = 'relative';
menuBtn.style.top = '4px';
menuBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
const items = [];
// Run now stays in the kebab too (alongside the new Run button on the
// card) for users coming from muscle-memory / mobile long-press.
if (task.status !== 'completed') items.push({ label: 'Run now', icon: '<polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>', action: () => _doRunNow(task.id) });
items.push({ label: 'Edit', icon: '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>', action: () => _showForm(task) });
if (task.status === 'active') items.push({ label: 'Pause', icon: '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>', action: () => _doPause(task.id) });
else if (task.status === 'paused') items.push({ label: 'Resume', icon: '<polygon points="5 3 19 12 5 21 5 3"/>', action: () => _doResume(task.id) });
items.push({ label: 'History', icon: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', action: () => _showRunHistory(task.id, task.name) });
if (task.is_builtin && task.is_modified) {
items.push({ label: 'Revert to default', icon: '<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>', action: () => _doRevert(task.id) });
}
items.push({ label: 'Delete', icon: '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>', action: () => _doDelete(task.id), danger: true });
_showTaskDropdown(menuBtn, items);
});
actionsWrap.appendChild(menuBtn);
// Run now — promoted out of the kebab onto the card itself for one-click
// manual triggering. Hidden for completed tasks (same gate as before).
if (task.status !== 'completed') {
const runBtn = document.createElement('button');
runBtn.className = 'memory-item-btn task-card-run-btn';
runBtn.title = 'Run now';
runBtn.style.cssText = 'position:relative;top:4px;margin-right:4px;display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 6px;';
runBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Run</span>';
runBtn.addEventListener('click', (e) => { e.stopPropagation(); _doRunNow(task.id); });
actionsWrap.insertBefore(runBtn, menuBtn);
}
titleRow.appendChild(actionsWrap);
// Content area
const content = document.createElement('div');
content.style.cssText = 'flex:1;min-width:0;position:relative;top:1px;';
content.appendChild(titleRow);
// Slim meta line (always visible): schedule · next · run count.
const metaParts = [_scheduleLabel(task)];
if (task.next_run && task.status === 'active') metaParts.push('Next: ' + _relativeTime(task.next_run));
if (task.run_count > 0) metaParts.push(task.run_count + ' run' + (task.run_count !== 1 ? 's' : ''));
const meta = document.createElement('div');
meta.className = 'memory-item-meta';
meta.style.cssText = 'font-size:10px;opacity:0.4;margin-top:-1px;';
meta.textContent = metaParts.join(' · ');
content.appendChild(meta);
// Expandable detail (revealed on click) — like the library doc/chat cards:
// extra meta + last-run result + description.
const detail = document.createElement('div');
detail.style.cssText = 'display:none;margin-top:7px;padding:8px 0 2px;border-top:1px solid var(--border);';
const extra = [];
if (task.last_run) extra.push('Last: ' + _relativeTime(task.last_run));
if (task.output_target && task.output_target !== 'session') extra.push('→ ' + task.output_target.replace(/^mcp__/, '').replace(/__/g, ' '));
if (task.model) extra.push('model: ' + (task.model.split('/').pop() || task.model));
if (extra.length) {
const ex = document.createElement('div');
ex.style.cssText = 'font-size:10px;opacity:0.4;margin-bottom:6px;';
ex.textContent = extra.join(' · ');
detail.appendChild(ex);
}
if (task.last_run_status) {
const isErr = task.last_run_status === 'error';
const color = isErr ? 'var(--red,#e06c75)' : 'var(--green,#50fa7b)';
const result = (task.last_run_result || '').trim();
const prev = result.length > 200 ? result.slice(0, 200) + '…' : result;
const lr = document.createElement('div');
lr.style.cssText = `font-size:11px;margin-bottom:6px;padding:4px 8px;border-left:2px solid ${color};background:color-mix(in srgb, ${color} 8%, transparent);border-radius:2px;line-height:1.4;cursor:pointer;`;
lr.innerHTML = `<span style="font-weight:600;color:${color};">${isErr ? '✗' : '✓'}</span> <span style="opacity:0.9;">${_esc(prev) || (isErr ? 'Failed (no detail)' : 'Success (no output)')}</span>`;
lr.title = 'Open full history';
lr.addEventListener('click', (e) => { e.stopPropagation(); _showRunHistory(task.id, task.name); });
detail.appendChild(lr);
}
const taskType = task.task_type || 'llm';
const p = task.prompt || '';
if (p || taskType === 'action') {
const desc = document.createElement('div');
desc.style.cssText = 'font-size:11px;opacity:0.6;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word;';
if (taskType === 'action') {
const am = (_builtinActions || []).find(a => a.name === task.action);
desc.textContent = am?.description || task.action || '—';
} else {
desc.textContent = p;
}
detail.appendChild(desc);
}
content.appendChild(detail);
// Select-mode checkbox (mirrors the library's .memory-select-cb).
if (_taskSelectMode) {
if (_taskSelected.has(task.id)) card.classList.add('selected');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'memory-select-cb';
cb.checked = _taskSelected.has(task.id);
cb.addEventListener('click', (e) => e.stopPropagation());
cb.addEventListener('change', () => {
if (cb.checked) _taskSelected.add(task.id); else _taskSelected.delete(task.id);
card.classList.toggle('selected', cb.checked);
_taskUpdateBulkCount();
const sa = document.getElementById('tasks-select-all');
if (sa) sa.checked = _tasks.length > 0 && _tasks.every(t => _taskSelected.has(t.id));
});
titleRow.insertBefore(cb, titleRow.firstChild);
}
// Title-row click: in select mode toggle the checkbox; otherwise expand.
titleRow.addEventListener('click', (e) => {
if (card._suppressNextClick) return; // long-press just opened the menu
if (e.target.closest('.memory-item-actions')) return;
if (_taskSelectMode) {
if (e.target.classList.contains('memory-select-cb')) return;
const cb = titleRow.querySelector('.memory-select-cb');
if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }
return;
}
const open = detail.style.display === 'none';
detail.style.display = open ? '' : 'none';
card.classList.toggle('expanded', open);
});
// Long-press (mobile) opens the ⋮ actions menu.
_attachTaskLongPress(card, menuBtn);
card.appendChild(content);
list.appendChild(card);
}
// Domino-in cascade on the first render-with-cards after opening — same
// staggered entrance the gallery / document library uses. We consume the
// flag here OR in the early-return branches above so subsequent re-renders
// (search, filter, edit) don't replay it. Note: opening with 0 tasks AND
// hitting the early-return ALSO clears the flag, so creating a first task
// afterwards won't replay the cascade — keeps the entrance scoped to the
// very first render of the panel.
if (_tasksCascadeNext && list.children.length) {
list.classList.remove('tasks-just-opened');
void list.offsetWidth; // force reflow so the class re-fires on re-add
list.classList.add('tasks-just-opened');
setTimeout(() => list.classList.remove('tasks-just-opened'), 900);
}
_tasksCascadeNext = false;
}
function _btn(label, onClick) {
const b = document.createElement('button');
b.className = 'task-btn';
b.textContent = label;
b.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
return b;
}
function _esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// Long-press a task card (mobile) to open its ⋮ actions menu. Hold 500ms;
// moving the finger >10px or releasing early cancels. Mirrors the library.
function _attachTaskLongPress(card, menuBtn) {
let hold = null, start = null;
const cancel = () => { if (hold) { clearTimeout(hold); hold = null; } start = null; };
card.addEventListener('pointerdown', (e) => {
if (e.target.closest('.memory-item-actions, .memory-select-cb, button, a, input')) return;
start = { x: e.clientX, y: e.clientY };
hold = setTimeout(() => {
hold = null;
card._suppressNextClick = true;
setTimeout(() => { card._suppressNextClick = false; }, 400);
if (navigator.vibrate) { try { navigator.vibrate(15); } catch (_) {} }
menuBtn.click();
}, 500);
});
card.addEventListener('pointermove', (e) => {
if (start && Math.hypot(e.clientX - start.x, e.clientY - start.y) > 10) cancel();
});
card.addEventListener('pointerup', cancel);
card.addEventListener('pointercancel', cancel);
}
function _showTaskDropdown(anchor, items) {
// Remove any existing dropdown
document.querySelectorAll('.task-dropdown').forEach(d => d.remove());
const dd = document.createElement('div');
dd.className = 'task-dropdown';
dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
items.forEach(item => {
const btn = document.createElement('button');
btn.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:6px 10px;border:none;background:none;color:var(--fg);font-size:11px;font-family:inherit;cursor:pointer;border-radius:4px;transition:background 0.1s;';
if (item.danger) btn.style.color = 'var(--color-error)';
if (item.icon) {
btn.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0;">${item.icon}</svg><span>${item.label}</span>`;
} else {
btn.textContent = item.label;
}
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; });
btn.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); });
dd.appendChild(btn);
});
document.body.appendChild(dd);
const rect = anchor.getBoundingClientRect();
let top = rect.bottom + 4;
let left = rect.right - dd.offsetWidth;
if (left < 8) left = 8;
if (top + dd.offsetHeight > window.innerHeight - 8) top = rect.top - dd.offsetHeight - 4;
dd.style.top = top + 'px';
dd.style.left = left + 'px';
const openedAt = performance.now();
const close = (e) => {
// Ignore any clicks that occur within 250ms of the open (covers touch
// "ghost click" duplicates that were firing right after pointerup and
// removing the dropdown before the user could see it).
if (performance.now() - openedAt < 250) return;
if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); }
};
// requestAnimationFrame so the listener is registered AFTER the current
// pointer/click event cycle has finished bubbling.
requestAnimationFrame(() => document.addEventListener('click', close));
}
// ---- Presets ----
const _TASK_PRESETS = [
{ label: 'Prompt on schedule', desc: 'Run a prompt daily, weekly, etc.', taskType: 'llm', triggerType: 'schedule' },
{ label: 'Prompt on event', desc: 'Trigger every N sessions or messages', taskType: 'llm', triggerType: 'event' },
{ label: 'Research on schedule', desc: 'Run deep research on a topic', taskType: 'research', triggerType: 'schedule' },
{ label: 'Research on event', desc: 'Run deep research after app events', taskType: 'research', triggerType: 'event' },
{ label: 'Action on schedule', desc: 'Run tidy/cleanup on a timer', taskType: 'action', triggerType: 'schedule' },
{ label: 'Action on event', desc: 'Run tidy/cleanup every N sessions or messages', taskType: 'action', triggerType: 'event' },
{ label: 'Webhook triggered', desc: 'Trigger via external HTTP call', taskType: 'llm', triggerType: 'webhook' },
];
// Icon for each preset, keyed off task/trigger type (24x24 stroke SVG).
function _presetIcon(p) {
const wrap = (inner) => `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.4;flex-shrink:0;">${inner}</svg>`;
if (p.taskType === 'research') return wrap('<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>');
if (p.taskType === 'action') return wrap('<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10z"/>'); // sparkle
if (p.triggerType === 'webhook') return wrap('<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><line x1="8" y1="12" x2="16" y2="12"/>'); // link
if (p.triggerType === 'event') return wrap('<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>'); // activity pulse
return wrap('<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>'); // clock (scheduled prompt)
}
function _showPresetPicker() {
const modal = document.getElementById('tasks-modal');
if (!modal) return;
const body = modal.querySelector('.modal-body');
if (!body) return;
let html = '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">';
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;"><h2 style="margin:0;padding:0;line-height:1;">Add Task</h2></div>';
html += '<p class="memory-desc" style="position:relative;top:4px;">Describe a task for the AI to draft, or pick a type below to set one up manually.</p>';
html += '<div class="task-ai-compose" style="display:flex;gap:6px;margin:6px 0 10px;">'
+ '<input type="text" id="task-ai-input" class="memory-search-input" style="flex:1;" placeholder="Describe a task — e.g. &quot;every weekday 7am summarize my unread email&quot;" />'
+ '<button class="memory-toolbar-btn active" id="task-ai-btn" title="Draft a task with AI" style="white-space:nowrap;height:28px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Draft with AI</button>'
+ '</div>';
html += '<div class="memory-list" style="max-height:none;flex:1;gap:0px;margin-top:2px;padding-right:8px;">';
_TASK_PRESETS.forEach((p, i) => {
html += `<button class="memory-item task-card" data-idx="${i}" style="cursor:pointer;text-align:left;width:100%;font-family:inherit;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:6px;">${_presetIcon(p)}<span class="memory-item-title" style="flex:1;position:relative;top:0px;">${p.label}</span></div>
<div style="font-size:10px;opacity:0.4;margin-top:-1px;position:relative;top:3px;">${p.desc}</div>
</div>
</button>`;
});
html += '</div>';
html += '</div>';
body.innerHTML = html;
body.querySelectorAll('.memory-item[data-idx]').forEach(card => {
card.addEventListener('click', () => {
const p = _TASK_PRESETS[parseInt(card.dataset.idx, 10)];
_showForm(null, p.taskType, p.triggerType);
});
});
document.getElementById('task-preset-cancel')?.addEventListener('click', () => _renderMainView());
// Describe a task in plain language → AI drafts the structured task + opens the form.
const aiInput = document.getElementById('task-ai-input');
const aiBtn = document.getElementById('task-ai-btn');
if (aiBtn && aiInput) {
aiInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); aiBtn.click(); } });
aiBtn.addEventListener('click', () => _aiDraftTask(aiInput, aiBtn));
}
}
// ---- Form ----
function _showForm(existing, initTaskType, initTriggerType) {
const modal = document.getElementById('tasks-modal');
if (!modal) return;
const body = modal.querySelector('.modal-body');
if (!body) return;
const curTaskType = existing?.task_type || initTaskType || 'llm';
const curTriggerType = existing?.trigger_type || initTriggerType || 'schedule';
body.innerHTML = `
<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
<h2 style="margin:0;padding:0;line-height:1;">${existing?.id ? 'Edit Task' : 'New Task'}</h2>
</div>
<p class="memory-desc">${existing?.id ? 'Update this tasks schedule, prompt, and output.' : 'Configure a prompt, research, or action to run automatically.'}</p>
<div class="task-form" style="flex:1;overflow-y:auto;min-height:0;">
<label class="task-form-label">Name</label>
<input type="text" id="task-form-name" class="task-form-input" value="${_esc(existing?.name || '')}" placeholder="${existing ? '' : 'Auto-generated if blank'}" />
<label class="task-form-label">Type</label>
<div class="task-form-toggle" id="task-form-type-toggle">
<button class="task-toggle-btn ${curTaskType === 'llm' ? 'active' : ''}" data-val="llm" style="position:relative;top:-4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>Prompt</button>
<button class="task-toggle-btn ${curTaskType === 'research' ? 'active' : ''}" data-val="research" style="position:relative;top:-4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Research</button>
<button class="task-toggle-btn ${curTaskType === 'action' ? 'active' : ''}" data-val="action" style="position:relative;top:-4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Action</button>
</div>
<div id="task-form-type-opts"></div>
<label class="task-form-label">Trigger</label>
<div class="task-form-toggle" id="task-form-trigger-toggle">
<button class="task-toggle-btn ${curTriggerType === 'schedule' ? 'active' : ''}" data-val="schedule" style="position:relative;top:-4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>Schedule</button>
<button class="task-toggle-btn ${curTriggerType === 'event' ? 'active' : ''}" data-val="event" style="position:relative;top:-4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>Event</button>
<button class="task-toggle-btn ${curTriggerType === 'webhook' ? 'active' : ''}" data-val="webhook" style="position:relative;top:-4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Webhook</button>
</div>
<div id="task-form-trigger-opts"></div>
<label class="task-form-label">Output</label>
<select id="task-form-output" class="task-form-input">
<option value="session">Session</option>
</select>
<label class="task-form-label">Model <span style="opacity:0.5;font-weight:normal;font-size:10px;">(optional — overrides session default)</span></label>
<select id="task-form-model" class="task-form-input">
<option value="">Use session default</option>
</select>
<label class="task-form-label">Chain</label>
<select id="task-form-chain" class="task-form-input">
<option value="">None</option>
</select>
<label class="task-form-label" style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<input type="checkbox" id="task-form-notif" ${existing && existing.notifications_enabled === false ? '' : 'checked'} style="margin:0;cursor:pointer;">
<span>Notifications</span>
<span style="opacity:0.55;font-weight:normal;font-size:10px;">— uncheck to silence completion notifications for this task (helpful for chatty cron jobs)</span>
</label>
<div class="task-form-actions">
<button id="task-form-cancel" class="memory-toolbar-btn"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-1px;margin-right:4px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Cancel</button>
<button id="task-form-save" class="memory-toolbar-btn active"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><polyline points="20 6 9 17 4 12"/></svg>${existing?.id ? 'Save' : 'Create'}</button>
</div>
</div>
</div>
`;
// --- Task type toggle ---
let taskType = curTaskType;
const typeToggle = document.getElementById('task-form-type-toggle');
const typeOpts = document.getElementById('task-form-type-opts');
function renderTypeOpts() {
typeOpts.innerHTML = '';
if (taskType === 'llm' || taskType === 'research') {
const placeholder = taskType === 'research' ? 'What should be researched?' : 'What should the AI do?';
typeOpts.innerHTML = `
<label class="task-form-label">${taskType === 'research' ? 'Research question' : 'Prompt'}</label>
<textarea id="task-form-prompt" class="task-form-input task-form-textarea" rows="4" placeholder="${placeholder}">${existing?.prompt || ''}</textarea>
`;
} else {
typeOpts.innerHTML = `
<label class="task-form-label">Action</label>
<select id="task-form-action" class="task-form-input">
<option value="">Loading…</option>
</select>
<div id="task-form-action-extra"></div>
`;
const syncActionExtra = async () => {
const sel = document.getElementById('task-form-action');
const extra = document.getElementById('task-form-action-extra');
if (!sel || !extra) return;
if (sel.value !== 'check_email_urgency') {
extra.innerHTML = '';
return;
}
extra.innerHTML = `
<label class="task-form-label">Email triage rules</label>
<textarea id="task-form-urgent-email-prompt" class="task-form-input task-form-textarea" rows="4" placeholder="What should count as urgent? e.g. deadlines, blockers, people waiting outside."></textarea>
<div class="memory-desc" style="font-size:11px;margin-top:4px;">Pause/resume and schedule are controlled by this task. It tags urgent, reply-soon, newsletter, marketing, and spam. Urgent/reply-soon emails use your reminder settings.</div>
`;
const settings = await _fetchUrgentEmailSettings();
const promptEl = document.getElementById('task-form-urgent-email-prompt');
if (promptEl && !promptEl.dataset.loaded) {
promptEl.value = settings.urgent_email_prompt || '';
promptEl.dataset.loaded = '1';
}
const notifEl = document.getElementById('task-form-notif');
if (notifEl && !existing?.id) notifEl.checked = false;
};
_fetchActions().then(actions => {
const sel = document.getElementById('task-form-action');
if (!sel) return;
sel.innerHTML = '';
for (const a of actions) {
const opt = document.createElement('option');
opt.value = a.name;
opt.textContent = `${a.name}${a.description}`;
if (existing?.action === a.name) opt.selected = true;
sel.appendChild(opt);
}
sel.addEventListener('change', syncActionExtra);
syncActionExtra();
});
}
}
typeToggle.addEventListener('click', (e) => {
const btn = e.target.closest('.task-toggle-btn');
if (!btn) return;
taskType = btn.dataset.val;
typeToggle.querySelectorAll('.task-toggle-btn').forEach(b => b.classList.toggle('active', b.dataset.val === taskType));
renderTypeOpts();
});
renderTypeOpts();
// --- Trigger type toggle ---
let triggerType = curTriggerType;
const triggerToggle = document.getElementById('task-form-trigger-toggle');
const triggerOpts = document.getElementById('task-form-trigger-opts');
function renderTriggerOpts() {
triggerOpts.innerHTML = '';
if (triggerType === 'schedule') {
triggerOpts.innerHTML = `
<label class="task-form-label">Frequency</label>
<select id="task-form-schedule" class="task-form-input">
<option value="daily" ${(!existing || existing.schedule === 'daily') ? 'selected' : ''}>Daily</option>
<option value="weekly" ${existing?.schedule === 'weekly' ? 'selected' : ''}>Weekly</option>
<option value="monthly" ${existing?.schedule === 'monthly' ? 'selected' : ''}>Monthly</option>
<option value="once" ${existing?.schedule === 'once' ? 'selected' : ''}>Once</option>
<option value="cron" ${existing?.schedule === 'cron' ? 'selected' : ''}>Cron</option>
</select>
<div id="task-form-schedule-opts"></div>
<div id="task-form-time-section">
<label class="task-form-label">Time</label>
<div class="task-time-picker" id="task-form-time-wrap"></div>
</div>
`;
// Build time picker
let initH = 9, initM = 0;
if (existing && existing.scheduled_time) {
const [uh, um] = existing.scheduled_time.split(':').map(Number);
const d = new Date();
d.setUTCHours(uh, um, 0, 0);
initH = d.getHours();
initM = d.getMinutes();
}
_buildTimePicker('task-form-time-wrap', initH, initM);
const schedSelect = document.getElementById('task-form-schedule');
const schedOpts = document.getElementById('task-form-schedule-opts');
function updateScheduleOpts() {
schedOpts.innerHTML = '';
const sched = schedSelect.value;
const timeSection = document.getElementById('task-form-time-section');
if (timeSection) timeSection.style.display = sched === 'cron' ? 'none' : '';
if (sched === 'weekly') {
const label = document.createElement('label');
label.className = 'task-form-label';
label.textContent = 'Day of week';
schedOpts.appendChild(label);
const sel = document.createElement('select');
sel.id = 'task-form-day';
sel.className = 'task-form-input';
DAYS_OF_WEEK.forEach((day, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = day;
if (existing && existing.scheduled_day === i) opt.selected = true;
sel.appendChild(opt);
});
schedOpts.appendChild(sel);
} else if (sched === 'monthly') {
const label = document.createElement('label');
label.className = 'task-form-label';
label.textContent = 'Day of month';
schedOpts.appendChild(label);
const inp = document.createElement('input');
inp.type = 'number';
inp.id = 'task-form-day';
inp.className = 'task-form-input';
inp.min = 1; inp.max = 31;
inp.value = existing?.scheduled_day ?? 1;
schedOpts.appendChild(inp);
} else if (sched === 'once') {
const label = document.createElement('label');
label.className = 'task-form-label';
label.textContent = 'Date';
schedOpts.appendChild(label);
const dateWrap = document.createElement('div');
dateWrap.className = 'task-date-picker';
dateWrap.id = 'task-form-date';
schedOpts.appendChild(dateWrap);
_buildDatePicker('task-form-date', existing?.scheduled_date ? new Date(existing.scheduled_date) : new Date());
} else if (sched === 'cron') {
const label = document.createElement('label');
label.className = 'task-form-label';
label.textContent = 'Cron expression';
schedOpts.appendChild(label);
const inp = document.createElement('input');
inp.type = 'text';
inp.id = 'task-form-cron';
inp.className = 'task-form-input';
inp.placeholder = '*/30 * * * *';
inp.value = existing?.cron_expression || '';
schedOpts.appendChild(inp);
const hint = document.createElement('div');
hint.style.cssText = 'font-size:10px;opacity:0.4;margin-top:2px;';
hint.textContent = 'min hour day month weekday — e.g. "0 */2 * * *" = every 2 hours';
schedOpts.appendChild(hint);
}
}
schedSelect.addEventListener('change', updateScheduleOpts);
updateScheduleOpts();
} else if (triggerType === 'event') {
triggerOpts.innerHTML = `
<label class="task-form-label">Event</label>
<select id="task-form-event" class="task-form-input">
<option value="">Loading…</option>
</select>
<label class="task-form-label">Every N occurrences</label>
<input type="number" id="task-form-trigger-count" class="task-form-input" min="1" max="1000" value="${existing?.trigger_count || 5}" />
`;
_fetchEvents().then(events => {
const sel = document.getElementById('task-form-event');
if (!sel) return;
sel.innerHTML = '';
for (const ev of events) {
const opt = document.createElement('option');
opt.value = ev.name;
opt.textContent = `${ev.name}${ev.description}`;
if (existing?.trigger_event === ev.name) opt.selected = true;
sel.appendChild(opt);
}
});
} else if (triggerType === 'webhook') {
if (existing?.webhook_token) {
const url = `${API_BASE}/api/tasks/${existing.id}/webhook/${existing.webhook_token}`;
triggerOpts.innerHTML = `
<label class="task-form-label">Webhook URL</label>
<div style="display:flex;gap:4px;align-items:center;">
<input type="text" class="task-form-input" value="${url}" readonly style="flex:1;font-size:11px;opacity:0.8;" id="task-form-webhook-url" />
<button class="task-btn" id="task-form-webhook-copy" style="white-space:nowrap;">Copy</button>
</div>
<div style="font-size:10px;opacity:0.4;margin-top:4px;">POST this URL from any external service to trigger the task. No auth needed.</div>
`;
document.getElementById('task-form-webhook-copy')?.addEventListener('click', () => {
navigator.clipboard.writeText(url);
if (uiModule) uiModule.showToast('Copied');
});
} else {
triggerOpts.innerHTML = '<div style="font-size:11px;opacity:0.5;margin-top:4px;">Webhook URL will be generated when the task is saved.</div>';
}
}
}
triggerToggle.addEventListener('click', (e) => {
const btn = e.target.closest('.task-toggle-btn');
if (!btn) return;
triggerType = btn.dataset.val;
triggerToggle.querySelectorAll('.task-toggle-btn').forEach(b => b.classList.toggle('active', b.dataset.val === triggerType));
renderTriggerOpts();
});
renderTriggerOpts();
// Populate output targets
_fetchOutputTargets().then(targets => {
const outputSel = document.getElementById('task-form-output');
if (!outputSel || targets.length <= 1) return;
outputSel.innerHTML = '';
let matchedOutput = false;
for (const t of targets) {
const opt = document.createElement('option');
opt.value = t.value;
opt.textContent = t.label;
if (existing?.output_target === t.value) {
opt.selected = true;
matchedOutput = true;
}
outputSel.appendChild(opt);
}
if (existing?.output_target && !matchedOutput) {
const opt = document.createElement('option');
opt.value = existing.output_target;
opt.textContent = existing.output_target.includes('@') ? `Email: ${existing.output_target}` : existing.output_target;
opt.selected = true;
outputSel.appendChild(opt);
}
});
// Populate model dropdown from /api/models. Value is "endpoint_url::model"
// so a single field encodes both the model name and which endpoint to call.
// Blank value (option 0) = inherit session default.
fetch(`${API_BASE}/api/models`, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const modelSel = document.getElementById('task-form-model');
if (!modelSel) return;
const items = (data.items || []).filter(it => (it.model_type || 'llm') === 'llm');
const curKey = existing?.endpoint_url && existing?.model
? `${existing.endpoint_url}::${existing.model}`
: '';
for (const it of items) {
if (it.offline || !it.models || it.models.length === 0) continue;
const group = document.createElement('optgroup');
group.label = it.endpoint_name || it.host || 'endpoint';
const all = [...(it.models || []), ...(it.models_extra || [])];
for (const m of all) {
const opt = document.createElement('option');
opt.value = `${it.url}::${m}`;
opt.textContent = m;
if (opt.value === curKey) opt.selected = true;
group.appendChild(opt);
}
modelSel.appendChild(group);
}
// Preserve a previously-set pairing even if /api/models doesn't list it
// anymore (e.g. endpoint disabled). Shows so the user knows it's set.
if (curKey && modelSel.value !== curKey) {
const opt = document.createElement('option');
opt.value = curKey;
opt.textContent = `${existing.model} (unlisted endpoint)`;
opt.selected = true;
modelSel.appendChild(opt);
}
})
.catch(() => {});
// Populate chain dropdown
const chainSel = document.getElementById('task-form-chain');
if (chainSel) {
const otherTasks = _tasks.filter(t => !existing || t.id !== existing.id);
for (const t of otherTasks) {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name;
if (existing?.then_task_id === t.id) opt.selected = true;
chainSel.appendChild(opt);
}
}
// Cancel — return to the Tasks tab (keeps the active-tab highlight in sync)
document.getElementById('task-form-cancel').addEventListener('click', () => {
_switchTab('tasks');
});
// Esc on the form goes back to the Add tab's preset picker (not the Tasks
// tab — Cancel handles that). Capture-phase + stopImmediatePropagation so
// app.js's generic modal-dismiss doesn't close the whole Tasks window first.
if (window._tasksFormEsc) document.removeEventListener('keydown', window._tasksFormEsc, true);
window._tasksFormEsc = (e) => {
if (e.key !== 'Escape') return;
if (!document.getElementById('task-form-save')) {
// Form is no longer in the DOM — detach to stop leaking.
document.removeEventListener('keydown', window._tasksFormEsc, true);
window._tasksFormEsc = null;
return;
}
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) {
t.blur();
return;
}
e.stopImmediatePropagation();
e.preventDefault();
_showPresetPicker();
};
document.addEventListener('keydown', window._tasksFormEsc, true);
// Save
document.getElementById('task-form-save').addEventListener('click', async () => {
const nameEl = document.getElementById('task-form-name');
const outputTarget = document.getElementById('task-form-output')?.value || 'session';
const payload = {
task_type: taskType,
trigger_type: triggerType,
output_target: outputTarget,
};
if (nameEl) payload.name = nameEl.value.trim() || undefined;
// Model / endpoint override. Blank = inherit session default. Otherwise
// value is `endpoint_url::model_id`.
const modelVal = document.getElementById('task-form-model')?.value || '';
if (modelVal) {
const idx = modelVal.indexOf('::');
if (idx > 0) {
payload.endpoint_url = modelVal.slice(0, idx);
payload.model = modelVal.slice(idx + 2);
}
} else {
// Explicitly clear so a previously-pinned task can return to default.
payload.endpoint_url = '';
payload.model = '';
}
// Chain
const chainVal = document.getElementById('task-form-chain')?.value;
payload.then_task_id = chainVal || '';
// Notifications toggle — defaults to true if absent.
const notifEl = document.getElementById('task-form-notif');
if (notifEl) payload.notifications_enabled = !!notifEl.checked;
// Task type specifics
if (taskType === 'llm' || taskType === 'research') {
const prompt = document.getElementById('task-form-prompt')?.value?.trim();
if (!prompt) {
if (uiModule) uiModule.showError('Prompt is required');
return;
}
payload.prompt = prompt;
} else {
const action = document.getElementById('task-form-action')?.value;
if (!action) {
if (uiModule) uiModule.showError('Select an action');
return;
}
payload.action = action;
if (action === 'check_email_urgency') {
const urgentPrompt = document.getElementById('task-form-urgent-email-prompt')?.value || '';
try {
await _saveUrgentEmailSettings(urgentPrompt);
} catch (e) {
if (uiModule) uiModule.showError('Failed to save urgency rules');
return;
}
}
}
// Trigger specifics
if (triggerType === 'schedule') {
const schedSelect = document.getElementById('task-form-schedule');
payload.schedule = schedSelect?.value || 'daily';
if (payload.schedule === 'cron') {
const cronVal = document.getElementById('task-form-cron')?.value?.trim();
if (!cronVal) {
if (uiModule) uiModule.showError('Cron expression is required');
return;
}
payload.cron_expression = cronVal;
} else {
const timeVal = _getTimePickerValue('task-form-time-wrap');
payload.scheduled_time = _localTimeToUtc(timeVal);
const dayInput = document.getElementById('task-form-day');
if (dayInput) payload.scheduled_day = parseInt(dayInput.value, 10);
if (payload.schedule === 'once' && document.getElementById('task-form-date')) {
const pickedDate = _getDatePickerValue('task-form-date');
const [h, m] = timeVal.split(':').map(Number);
pickedDate.setHours(h, m, 0, 0);
payload.scheduled_date = pickedDate.toISOString();
}
}
} else if (triggerType === 'event') {
const evSel = document.getElementById('task-form-event');
const countInput = document.getElementById('task-form-trigger-count');
if (!evSel?.value) {
if (uiModule) uiModule.showError('Select an event');
return;
}
payload.trigger_event = evSel.value;
payload.trigger_count = parseInt(countInput?.value || '5', 10);
}
// webhook: no extra fields needed, token is auto-generated server-side
try {
// Edit only when we have a real existing task (has an id). A draft
// object passed for AI pre-fill has no id → create via POST.
if (existing && existing.id) {
await _updateTask(existing.id, payload);
if (uiModule) uiModule.showToast('Task updated');
} else {
await _createTask(payload);
if (uiModule) uiModule.showToast('Task created');
}
await _fetchTasks();
_switchTab('tasks');
} catch (e) {
if (uiModule) uiModule.showError(e.message);
}
});
}
// ---- Run History ----
async function _showRunHistory(taskId, taskName) {
_viewingRuns = taskId;
const modal = document.getElementById('tasks-modal');
if (!modal) return;
const body = modal.querySelector('.modal-body');
if (!body) return;
body.innerHTML = '';
body.appendChild(spinnerModule.createLoadingRow('Loading…'));
const runs = await _fetchRuns(taskId);
let html = `<div class="task-history-header">
<button id="task-history-back" class="task-btn">← Back</button>
<span style="font-size:13px;opacity:0.7;">${_esc(taskName)} — Run history</span>
</div>`;
if (runs.length === 0) {
html += '<div style="opacity:0.4;font-size:12px;text-align:center;padding:24px 0;">No runs yet.</div>';
} else {
html += '<div class="task-runs-list">';
for (const run of runs) {
const statusClass = run.status === 'success' ? 'task-run-success' : run.status === 'error' ? 'task-run-error' : 'task-run-running';
html += `<div class="task-run-item ${statusClass}">
<div class="task-run-item-header">
${_statusDot(run.status === 'success' ? 'active' : run.status)}
<span>${run.status}</span>
${run.model ? `<span class="task-run-model" style="font-size:10px;opacity:0.5;">${_esc(run.model.split('/').pop())}</span>` : ''}
<span class="task-run-time" title="${run.started_at ? _esc(_relativeTime(run.started_at)) : ''}">${run.started_at ? _absoluteTime(run.started_at) : ''}</span>
</div>
<div class="task-run-result">${_esc(run.result ? (run.result.length > 300 ? run.result.slice(0, 300) + '…' : run.result) : run.error || '—')}</div>
</div>`;
}
html += '</div>';
}
body.innerHTML = html;
document.getElementById('task-history-back').addEventListener('click', () => {
_viewingRuns = null;
_renderMainView();
});
// Click to expand/collapse result
body.querySelectorAll('.task-run-item').forEach((item, i) => {
const resultEl = item.querySelector('.task-run-result');
const run = runs[i];
if (!run.result || run.result.length <= 300) return;
let expanded = false;
resultEl.style.cursor = 'pointer';
resultEl.addEventListener('click', () => {
expanded = !expanded;
resultEl.textContent = expanded ? run.result : run.result.slice(0, 300) + '…';
});
});
}
// ---- Actions ----
async function _doPause(id) {
try {
await _pauseTask(id);
if (uiModule) uiModule.showToast('Task paused');
await _fetchTasks();
_renderMainView();
} catch (e) { if (uiModule) uiModule.showError(e.message); }
}
async function _doResume(id) {
try {
await _resumeTask(id);
if (uiModule) uiModule.showToast('Task resumed');
await _fetchTasks();
_renderMainView();
} catch (e) { if (uiModule) uiModule.showError(e.message); }
}
async function _doRunNow(id, force = false) {
try {
await _runNow(id, force);
if (uiModule) uiModule.showToast(force ? 'Task triggered in parallel' : 'Task triggered');
} catch (e) {
// Mirror the polling notification surface so the user sees the same kind
// of feedback they get for finished/failed tasks — a real browser
// Notification when permission is granted, toast fallback otherwise.
const msg = e.message || 'Failed to trigger task';
let fired = false;
try {
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
new Notification('Task', { body: msg, tag: 'task-runnow-' + id, icon: '/static/favicon.ico' });
fired = true;
}
} catch (_) {}
if (!fired && uiModule) uiModule.showError(msg);
}
}
async function _doDelete(id) {
const ok = uiModule?.styledConfirm
? await uiModule.styledConfirm('Delete this task and all its run history?', { confirmText: 'Delete', danger: true })
: confirm('Delete this task and all its run history?');
if (!ok) return;
try {
await _deleteTask(id);
await _animateTaskRemoval([id]);
if (uiModule) uiModule.showToast('Task deleted');
await _fetchTasks();
_renderMainView();
} catch (e) { if (uiModule) uiModule.showError(e.message); }
}
async function _doRevert(id) {
const ok = uiModule?.styledConfirm
? await uiModule.styledConfirm('Revert this built-in task to its default schedule and settings?', { confirmText: 'Revert' })
: confirm('Revert this built-in task to its default?');
if (!ok) return;
try {
const res = await fetch(`${API_BASE}/api/tasks/${id}/revert`, { method: 'POST', credentials: 'same-origin' });
if (!res.ok) throw new Error('Failed to revert task');
if (uiModule) uiModule.showToast('Reverted to default');
await _fetchTasks();
_renderMainView();
} catch (e) { if (uiModule) uiModule.showError(e.message); }
}
async function _doToggleAll() {
// If any task is active → pause all. Else resume all paused tasks.
const hasActive = _tasks.some(t => t.status === 'active');
const targets = _tasks.filter(t => t.status === (hasActive ? 'active' : 'paused'));
if (targets.length === 0) {
if (uiModule) uiModule.showToast('No tasks to ' + (hasActive ? 'pause' : 'resume'));
return;
}
const verb = hasActive ? 'Pause' : 'Resume';
let confirmed = true;
if (uiModule?.styledConfirm) {
confirmed = await uiModule.styledConfirm(
`${verb} all ${targets.length} ${hasActive ? 'active' : 'paused'} task(s)?`,
{ confirmText: verb + ' all' }
);
} else if (typeof confirm === 'function') {
confirmed = confirm(`${verb} ${targets.length} task(s)?`);
}
if (!confirmed) return;
let ok = 0, fails = [];
for (const t of targets) {
try {
if (hasActive) await _pauseTask(t.id);
else await _resumeTask(t.id);
ok++;
} catch (e) {
fails.push(t.name || t.id);
}
}
if (uiModule) {
if (fails.length === 0) uiModule.showToast(`${verb}d all ${ok} task(s)`);
else uiModule.showError(`${verb}d ${ok}/${targets.length} — failed: ${fails.slice(0, 3).join(', ')}`);
}
await _fetchTasks();
_renderMainView();
}
function _syncPauseAllButton() {
const btn = document.getElementById('tasks-pause-all-btn');
if (!btn) return;
const pauseIco = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px;"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>';
const playIco = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px;"><polygon points="6 4 20 12 6 20 6 4"/></svg>';
const hasActive = _tasks.some(t => t.status === 'active');
const hasPaused = _tasks.some(t => t.status === 'paused');
if (hasActive) {
btn.innerHTML = pauseIco + 'Pause all';
btn.title = 'Pause every active task';
btn.style.opacity = '1';
btn.disabled = false;
} else if (hasPaused) {
btn.innerHTML = playIco + 'Resume all';
btn.title = 'Resume every paused task';
btn.style.opacity = '1';
btn.disabled = false;
} else {
btn.innerHTML = pauseIco + 'Pause all';
btn.style.opacity = '0.4';
btn.disabled = true;
}
}
// ---- Tab routing ----
let _activeTab = 'tasks';
function _switchTab(tab) {
_activeTab = tab;
const modal = document.getElementById('tasks-modal');
if (!modal) return;
modal.querySelectorAll('.tasks-tab').forEach(b => {
const on = b.dataset.tab === tab;
b.setAttribute('aria-selected', on ? 'true' : 'false');
b.classList.toggle('active', on);
});
if (tab === 'tasks') _renderMainView();
else if (tab === 'activity') _renderActivityView();
else if (tab === 'new') _showPresetPicker();
}
// ---- Activity view (assistant session log) ----
async function _renderActivityView() {
const modal = document.getElementById('tasks-modal');
const body = modal?.querySelector('.modal-body');
if (!body) return;
body.innerHTML = `
<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
<h2 style="margin:0;padding:0;line-height:1;">Activity</h2>
<button class="memory-toolbar-btn" id="tasks-activity-refresh" title="Refresh" style="margin-left:auto;">Refresh</button>
</div>
<p class="memory-desc">Recent task runs across all scheduled tasks.</p>
<div style="display:flex;align-items:center;gap:6px;margin:6px 0 8px;">
<input type="text" id="tasks-activity-search" placeholder="Filter activity…" class="memory-search-input" style="flex:1;" />
</div>
<div class="tasks-activity-filters" id="tasks-activity-chips" style="display:flex;gap:5px;margin-bottom:8px;flex-wrap:wrap;"></div>
<div id="tasks-activity-list" class="memory-list" style="flex:1;overflow:auto;font-size:13px;"></div>
</div>
`;
document.getElementById('tasks-activity-refresh').addEventListener('click', _renderActivityView);
// Loading placeholder matches the document library: app whirlpool + label.
const _actList = document.getElementById('tasks-activity-list');
if (_actList) _actList.appendChild(spinnerModule.createLoadingRow('Loading…'));
// Solo filter: clicking a chip shows ONLY that group (a category, or
// Errors). Clicking the active chip again clears the filter (show all).
// At most one chip is active at a time. _solo holds the active key, or null.
let _afQuery = '';
let _solo = null; // 'cat:<Category>' | 'status:error' | null
const _entryCat = (e) => _categoryLabel(e.taskName);
const _entryStatus = (e) =>
(e.status === 'success' || _classifyResult(e.result) === 'ok') ? 'ok'
: (e.status === 'error' || _classifyResult(e.result) === 'error') ? 'error' : 'info';
const _isNotification = (e) => e.output_target === 'notification';
const _matchesSolo = (e) => {
// Notification rows are intentionally hidden from the default "All" view —
// they're surfaced via the dedicated "notifications" chip so the noisy
// alert stream doesn't drown the rest of the activity.
if (!_solo) return !_isNotification(e);
if (_solo === 'notifications') return _isNotification(e);
if (_solo.startsWith('cat:')) return _entryCat(e) === _solo.slice(4);
if (_solo === 'status:error') return _entryStatus(e) === 'error';
return true;
};
const _applyFilter = () => {
const list = document.getElementById('tasks-activity-list');
if (!list) return;
const q = _afQuery.trim().toLowerCase();
const filtered = _activityEntries.filter(e => {
if (!_matchesSolo(e)) return false;
if (q && !(`${e.taskName} ${e.result}`.toLowerCase().includes(q))) return false;
return true;
});
if (filtered.length === 0) {
list.innerHTML = '<div style="opacity:0.5;padding:12px;">No matching activity.</div>';
return;
}
list.innerHTML = _stackActivityEntries(filtered).map(_renderActivityEntry).join('');
_wireActivityRows(list);
};
const _buildChips = () => {
const chipBar = document.getElementById('tasks-activity-chips');
if (!chipBar) return;
// Distinct categories present (excluding notifications — those have their
// own chip and are hidden from the default view).
const cats = [];
for (const e of _activityEntries) {
if (_isNotification(e)) continue;
const c = _entryCat(e);
if (!cats.includes(c)) cats.push(c);
}
const hasErrors = _activityEntries.some(e => !_isNotification(e) && _entryStatus(e) === 'error');
// Count notifications that would actually display under the chip — applies
// the active search query so the badge matches what you'd see, not a
// misleading total.
const _q = _afQuery.trim().toLowerCase();
const notifCount = _activityEntries.filter(e =>
_isNotification(e) && (!_q || `${e.taskName} ${e.result}`.toLowerCase().includes(_q))
).length;
// Active chip is highlighted; when one is soloed the rest dim ('off').
// Library-style .memory-cat-chip, with an "all" chip; the active one
// highlights. Solo-select: clicking shows only that group.
const cls = (active) => 'memory-cat-chip' + (active ? ' active' : '');
let html = `<button class="${cls(!_solo)}" data-key="">all</button>`;
html += cats.map(c =>
`<button class="${cls(_solo === 'cat:' + c)}" data-key="cat:${c}">${_escHtml(c)}</button>`
).join('');
if (hasErrors) {
html += `<button class="${cls(_solo === 'status:error')}" data-key="status:error">errors</button>`;
}
if (notifCount) {
html += `<button class="${cls(_solo === 'notifications')}" data-key="notifications">notifications <span style="opacity:0.6;font-weight:normal;">${notifCount}</span></button>`;
}
chipBar.innerHTML = html;
chipBar.querySelectorAll('.memory-cat-chip').forEach(chip => {
chip.addEventListener('click', () => {
const key = chip.dataset.key;
_solo = key ? (_solo === key ? null : key) : null; // "all" or re-click clears
_buildChips();
_applyFilter();
});
});
};
const searchEl = document.getElementById('tasks-activity-search');
if (searchEl) searchEl.addEventListener('input', () => { _afQuery = searchEl.value; _buildChips(); _applyFilter(); });
try {
const res = await fetch(`${API_BASE}/api/tasks/runs/recent?limit=100`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const runs = data.runs || [];
const list = document.getElementById('tasks-activity-list');
if (!list) return;
if (runs.length === 0) {
list.innerHTML = '<div style="opacity:0.5;padding:12px;">No activity yet. Scheduled tasks will log here once they run.</div>';
return;
}
_activityEntries = runs.map(r => {
let resultText = r.result || r.error || '';
if (!resultText) {
if (r.status === 'queued') resultText = '_Queued — waiting for a free slot…_';
if (r.status === 'running') resultText = '_Running…_';
}
return {
// Surface the actual task_type ('llm' | 'research' | 'action') so the
// chat-worthy check in _renderActivityEntry can decide between "Open
// in chat" (llm/research) and "Copy log" (action). Was hardcoded
// 'task', which never matched and made Open-in-chat dead code.
kind: r.task_type || 'llm',
taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'),
taskId: r.task_id,
result: resultText,
prompt: '',
ts: r.finished_at || r.started_at,
status: r.status,
model: r.model || '',
endpointUrl: r.endpoint_url || '',
sessionId: r.session_id || '',
researchId: r.research_id || '',
output_target: r.output_target || 'session',
};
});
_buildChips();
_applyFilter();
} catch (e) {
const list = document.getElementById('tasks-activity-list');
if (list) list.innerHTML = `<div style="opacity:0.5;padding:12px;">Failed to load activity: ${_escHtml(e.message || String(e))}</div>`;
}
}
let _activityEntries = [];
function _stackActivityEntries(entries) {
const out = [];
const byKey = new Map();
const hourBucket = (ts) => {
const d = ts ? new Date(ts) : null;
if (!d || Number.isNaN(d.getTime())) return '';
d.setMinutes(0, 0, 0);
return d.toISOString();
};
const normalizeResult = (entry) => {
const text = (entry.result || '').trim();
if (/^Email\b/i.test(entry.taskName || '')) {
if (/^skipped\s*[—-]/i.test(text) || /\bNo recent emails\b/i.test(text)) {
return text.replace(/\d+/g, '#');
}
return '__email_run__';
}
return text;
};
for (const entry of entries) {
const key = [
entry.taskId || '',
entry.taskName || '',
entry.kind || '',
entry.status || '',
entry.output_target || '',
normalizeResult(entry),
/^Email\b/i.test(entry.taskName || '') ? hourBucket(entry.ts) : '',
].join('\u0001');
const existing = byKey.get(key);
if (existing && entry.status !== 'running' && entry.status !== 'queued') {
existing.repeatCount = (existing.repeatCount || 1) + 1;
continue;
}
const stacked = { ...entry, repeatCount: 1, sourceIdx: _activityEntries.indexOf(entry) };
byKey.set(key, stacked);
out.push(stacked);
}
return out;
}
// "5s" / "1m 23s" / "2h 14m" — same compact ladder the activity timestamps use.
function _fmtElapsed(ms) {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return s + 's';
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ' + (s % 60) + 's';
const h = Math.floor(m / 60);
return h + 'h ' + (m % 60) + 'm';
}
// Single 1-second interval ticks all running rows' elapsed counters.
// Started lazily when a running row appears, cleared when none remain.
let _activityTimerInterval = null;
function _startActivityTimers(root) {
// Tick once immediately so the freshly-rendered row jumps to the right
// value before the interval fires.
_tickActivityTimers(root || document);
if (_activityTimerInterval) return;
_activityTimerInterval = setInterval(() => {
if (!_tickActivityTimers(document)) {
// No live rows anymore — stop the interval to avoid burning a tick.
clearInterval(_activityTimerInterval);
_activityTimerInterval = null;
}
}, 1000);
}
function _tickActivityTimers(root) {
const els = (root || document).querySelectorAll('.task-log-running-elapsed[data-since]');
if (!els.length) return false;
const now = Date.now();
els.forEach(el => {
const since = parseInt(el.dataset.since, 10);
if (since) el.textContent = _fmtElapsed(now - since);
});
return true;
}
// Wire row interactions: expand toggle + "Open in chat".
function _wireActivityRows(list) {
// Replace the [data-spin-here] placeholders in running/queued rows with the
// app's whirlpool spinner element (createElement, with a stop hook so the
// poll's next render clears them cleanly).
list.querySelectorAll('[data-spin-here]').forEach(slot => {
try {
const wp = spinnerModule.createWhirlpool(12);
// Right-side placement (next to the "Running" label) — small left
// margin to separate from the text, no right margin so the spinner
// sits flush with the row's right edge.
wp.element.style.cssText = 'display:inline-flex;width:12px;height:12px;margin:0 0 0 6px;vertical-align:middle;';
slot.replaceWith(wp.element);
} catch (_) {
slot.textContent = '…';
}
});
// Kick the live elapsed-timer interval (running rows only — queued has no
// counter). No-op when there's nothing to tick.
_startActivityTimers(list);
list.querySelectorAll('.task-log-row').forEach(row => {
// Click anywhere on the (non-running, non-skipped) row to toggle expand.
// Buttons inside still get their own handlers via stopPropagation.
if (!row.classList.contains('is-running') && !row.classList.contains('is-skipped')) {
row.addEventListener('click', () => row.classList.toggle('expanded'));
}
row.querySelector('.task-log-row-toggle')?.addEventListener('click', (e) => {
e.stopPropagation();
row.classList.toggle('expanded');
});
row.querySelector('.task-log-open-chat')?.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (entry) _openResultInChat(entry);
});
row.querySelector('.task-log-open-report')?.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (entry?.researchId) window.open(`${API_BASE}/api/research/report/${encodeURIComponent(entry.researchId)}`, '_blank');
});
row.querySelector('.task-log-force-run')?.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (entry?.taskId) _doRunNow(entry.taskId, true);
});
row.querySelector('.task-log-copy')?.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (!entry) return;
const txt = `${entry.taskName || ''}\n${entry.result || ''}`.trim();
try {
uiModule.copyToClipboard(txt);
uiModule.showToast('Log copied');
} catch (_) { uiModule.showError('Copy failed'); }
});
});
}
// Open a task run's result in a fresh chat session so it's comfortable
// to read full-width and the user can ask follow-ups.
async function _openResultInChat(entry) {
try {
// Pick an endpoint/model. Prefer the model the task actually ran on
// (if it's currently reachable), else fall back to the first online
// endpoint. The user can switch models in the chat anyway.
let url = '', model = '', epId = '';
const items = (() => {
try { return (window.modelsModule && window.modelsModule.getCachedItems) ? window.modelsModule.getCachedItems() : []; }
catch { return []; }
})();
if (entry.model) {
// Find an online endpoint that serves the task's model.
const match = items.find(it => !it.offline && (it.models || []).includes(entry.model));
if (match) { url = match.url; model = entry.model; epId = match.endpoint_id || ''; }
else if (entry.endpointUrl) {
// Endpoint known but not in the live list (e.g. cookbook model
// not currently served) — try it anyway with skip_validation.
url = entry.endpointUrl; model = entry.model;
}
}
if (!url) {
try {
const dcRes = await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' });
const dc = dcRes.ok ? await dcRes.json() : {};
url = dc.endpoint_url || '';
model = dc.model || model || '';
epId = dc.endpoint_id || '';
} catch (_) {}
}
if (!url) {
// Skip embedding/tts/whisper/moderation/image models — they can't chat,
// and an endpoint may list one first (e.g. text-embedding-ada-002).
const _isChatModel = (m) => {
const l = (m || '').toLowerCase();
return !!l && !['text-embedding', 'embedding', 'tts-', 'whisper', 'text-moderation', 'moderation-', 'dall-e', 'rerank'].some(p => l.includes(p));
};
const online = items.find(it => !it.offline && (it.models || []).some(_isChatModel))
|| items.find(it => !it.offline && (it.models || []).length);
if (online) {
url = online.url;
model = (online.models || []).find(_isChatModel) || (online.models || [])[0];
epId = online.endpoint_id || '';
}
}
const fd = new FormData();
fd.append('name', `Task: ${entry.taskName}`.slice(0, 60));
fd.append('skip_validation', 'true');
if (url) fd.append('endpoint_url', url);
if (model) fd.append('model', model);
if (epId) fd.append('endpoint_id', epId);
const res = await fetch(`${API_BASE}/api/session`, { method: 'POST', credentials: 'same-origin', body: fd });
if (!res.ok) { uiModule.showToast(`Couldn't create chat (HTTP ${res.status})`); return; }
const sess = await res.json();
const sid = sess.id || sess.session_id;
if (!sid) { uiModule.showToast('Chat created but no session id returned'); return; }
// Seed the conversation: a framing user line + the result as assistant.
await fetch(`${API_BASE}/api/session/${sid}/inject_messages`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [
{ role: 'user', content: `Here is the latest run of my scheduled task "${entry.taskName}". Let's review it.` },
{ role: 'assistant', content: entry.result || '(no output)' },
] }),
});
closeTasks();
if (window.sessionModule) {
if (window.sessionModule.loadSessions) await window.sessionModule.loadSessions();
if (window.sessionModule.selectSession) window.sessionModule.selectSession(sid);
}
} catch (e) {
uiModule.showToast(`Open in chat failed: ${e.message || e}`);
}
}
function _classifyResult(text) {
const t = (text || '').toLowerCase();
if (/\b(error|failed|failure|exception|traceback|could not|couldn't)\b/.test(t)) return 'error';
if (/\b(done|completed|success|ok|finished)\b/.test(t)) return 'ok';
return 'info';
}
// Category → fixed hue. Anything that doesn't match a keyword gets a stable
// hue derived from the task name's hash, so a recurring custom task keeps
// the same color from one run to the next.
const _CATEGORY_HUES = [
{ hue: 210, kw: /\b(email|inbox|mail|smtp|imap|reply|summary|spam|urgency)\b/i }, // blue — email
{ hue: 280, kw: /\b(research|web ?search|deep[-_ ]research|sources?|investigate)\b/i },// purple — research
{ hue: 35, kw: /\b(cookbook|model[-_ ]?(serve|download)|hf|huggingface|vllm|llama|ollama)\b/i }, // amber — cookbook
{ hue: 150, kw: /\b(calendar|event|meeting|appointment|schedule)\b/i }, // green — calendar
{ hue: 330, kw: /\b(reminder|note|notify|alert)\b/i }, // pink — reminders
{ hue: 10, kw: /\b(check[-_ ]?in|morning|evening|daily|standup)\b/i }, // red — check-ins
{ hue: 190, kw: /\b(memory|memories|remember|recall)\b/i }, // teal — memory
];
function _hashHue(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h) % 360;
}
function _categoryHue(taskName, kind) {
if (kind === 'you') return 220; // user message — neutral blue-grey
const t = (taskName || '').toLowerCase();
for (const c of _CATEGORY_HUES) {
if (c.kw.test(t)) return c.hue;
}
return _hashHue(t || 'task');
}
// Coarse category label for the activity filter chips. Mirrors the
// hue keyword groups so the chip color matches the row stripe.
const _CATEGORY_LABELS = [
{ label: 'email', kw: /\b(email|inbox|mail|smtp|imap|reply|spam|urgency)\b/i },
{ label: 'research', kw: /\b(research|web ?search|deep[-_ ]research|sources?|investigate)\b/i },
{ label: 'cookbook', kw: /\b(cookbook|model[-_ ]?(serve|download)|hf|huggingface|vllm|llama|ollama)\b/i },
{ label: 'calendar', kw: /\b(calendar|event|meeting|appointment|schedule)\b/i },
{ label: 'reminders', kw: /\b(reminder|note|notify|alert)\b/i },
{ label: 'check-in', kw: /\b(check[-_ ]?in|morning|evening|daily|standup)\b/i },
{ label: 'memory', kw: /\b(memory|memories|remember|recall)\b/i },
];
function _categoryLabel(taskName) {
const t = (taskName || '').toLowerCase();
for (const c of _CATEGORY_LABELS) if (c.kw.test(t)) return c.label;
return 'other';
}
function _renderActivityEntry(entry) {
// Canonical index into _activityEntries (map() passes the FILTERED
// index, which would be wrong) — used by the Open-in-chat handler.
const entryIdx = Number.isInteger(entry.sourceIdx) ? entry.sourceIdx : _activityEntries.indexOf(entry);
const repeatBadge = entry.repeatCount > 1
? `<span class="task-log-repeat" title="${entry.repeatCount} similar activity rows">+${entry.repeatCount - 1} repeats</span>`
: '';
const tsLabel = _relativeTime(entry.ts);
const tsAbs = entry.ts ? new Date(entry.ts).toLocaleString() : '';
// Prefer the run's own status (queued / running / success / error / skipped)
// over heuristic text classification. Fall back to text-scan for older
// rows where entry.status is missing.
let status;
if (entry.status === 'queued' || entry.status === 'running' || entry.status === 'skipped' || entry.status === 'aborted') {
status = entry.status;
} else if (entry.status === 'error') {
status = 'error';
} else if (entry.status === 'success') {
status = 'ok';
} else {
status = _classifyResult(entry.result);
}
const statusDot = `<span class="task-log-status task-log-status-${status}" title="${status}"></span>`;
// Render the result through markdown so code blocks, lists, links look right.
let resultHtml;
// Running / queued rows: body stays empty — the status now lives on the
// right side of the head row ("Running <whirlpool>"), wired below.
const _isRunning = entry.status === 'running' || entry.status === 'queued';
// Skipped (noop) rows: render as a slim, dimmed one-liner — no body, no
// actions, just `· name · skipped — reason · time`. CSS via .is-skipped.
const _isSkipped = entry.status === 'skipped';
if (_isRunning) {
resultHtml = '';
} else {
try {
resultHtml = markdownModule.processWithThinking(markdownModule.squashOutsideCode(entry.result || ''));
} catch {
resultHtml = `<pre style="white-space:pre-wrap;word-break:break-word;">${_escHtml(entry.result || '')}</pre>`;
}
}
// Bracketed prefixes like "[Default] No recent emails" — the fan-out across
// accounts joins per-account results. Style them as compact accent tags so
// the activity row reads as "<tag> message" instead of a wall of brackets.
// Skip <pre>/<code> blocks: bash output / tracebacks / numbered lists often
// contain "\n[N] ..." sequences that the prefix regex would otherwise mangle.
{
const tagRe = /(^|<p>|<br\s*\/?>|\n)\[([^\]\n<>]{1,40})\]\s*/g;
const replaceTags = (s) => s.replace(tagRe, '$1<span class="task-log-account-tag">$2</span> ');
// Split on whole <pre>...</pre> blocks (greedy match per block); only
// transform the outside-of-pre segments. Then do the same for any stray
// inline <code>...</code> spans inside the surviving outside text.
const parts = resultHtml.split(/(<pre[\s\S]*?<\/pre>)/i);
resultHtml = parts.map((seg, i) => {
if (i % 2 === 1) return seg; // odd indices = the <pre>…</pre> chunks, leave intact
const codeParts = seg.split(/(<code[\s\S]*?<\/code>)/i);
return codeParts.map((cs, j) => j % 2 === 1 ? cs : replaceTags(cs)).join('');
}).join('');
}
const lineCount = (entry.result || '').split('\n').length;
const long = (entry.result || '').length > 600 || lineCount > 8;
const promptHtml = entry.prompt
? `<details class="task-log-prompt"><summary>Prompt</summary><pre>${_escHtml(entry.prompt)}</pre></details>`
: '';
const hue = _categoryHue(entry.taskName, entry.kind);
// CSS vars feed the colored title + accent stripe.
const styleVars = `--cat-hue:${hue};`;
const hasResult = !!(entry.result && entry.result.trim() && entry.status !== 'running' && entry.status !== 'queued');
// "Open in chat" only makes sense for runs whose result is a real assistant
// message (Prompt / Research tasks). Action/event runs are just log lines
// (e.g. "No recent emails", "Tidied N memories") — for those, replace the
// button with "Copy log" so you can grab the text without spawning a chat
// with nothing useful in it.
const _isChatWorthy = entry.kind === 'llm' || entry.kind === 'research';
let actionBtn = '';
if (hasResult && _isChatWorthy) {
actionBtn = `<button class="task-log-open-chat" type="button" title="Open this result in a chat to read full-width + ask follow-ups">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Open in chat
</button>`;
if (entry.kind === 'research' && entry.researchId) {
actionBtn += `<button class="task-log-open-report" type="button" title="Open the visual research report">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/></svg>
Visual report
</button>`;
}
} else if (hasResult) {
actionBtn = `<button class="task-log-copy" type="button" title="Copy this log entry">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy log
</button>`;
}
// Running rows replace the relative-time on the right with "Running NN" + a
// live whirlpool spinner. Queued shows "Queued" the same way (no timer —
// hasn't actually started yet). The elapsed counter ticks every second via
// `_startActivityTimers` after the row is in the DOM.
let rightHtml;
if (_isRunning) {
const isQueued = entry.status === 'queued';
const label = isQueued ? 'Queued' : 'Running';
// Initial elapsed for the first paint; the 1s interval below keeps it live.
const startMs = entry.ts ? new Date(entry.ts).getTime() : Date.now();
const elapsedInit = isQueued ? '' : `<span class="task-log-running-elapsed" data-since="${startMs}">${_fmtElapsed(Date.now() - startMs)}</span>`;
const forceBtn = isQueued && entry.taskId ? `<button class="task-log-force-run" type="button" title="Start now in parallel, bypassing the queue" style="border:0;background:transparent;box-shadow:none;margin-left:5px;padding:0;width:12px;height:12px;display:inline-flex;align-items:center;justify-content:center;font-size:10px;line-height:1;color:inherit;opacity:.8;"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" style="display:block;"><polygon points="6 4 20 12 6 20 6 4"/></svg></button>` : '';
rightHtml = `<span class="task-log-running-inline"><span class="task-log-running-label">${label}</span>${elapsedInit}<span data-spin-here="1"></span>${forceBtn}</span>`;
} else {
rightHtml = `<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>`;
}
// Slim variant for skipped (noop) rows — single line, no body, no actions,
// dimmed. The reason (entry.result, e.g. "no pings due") sits inline so
// users can see *why* the row was skipped without expanding anything.
if (_isSkipped) {
const reason = (entry.result || '').trim();
return `
<div class="task-log-row is-skipped" data-kind="${_escHtml(entry.kind)}" data-entry-idx="${entryIdx}" style="${styleVars}">
<div class="task-log-row-head">
${statusDot}
<span class="task-log-name">${_escHtml(entry.taskName)}</span>
${repeatBadge}
<span class="task-log-skipped-reason">skipped${reason ? ' — ' + _escHtml(reason) : ''}</span>
<span style="flex:1"></span>
<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>
</div>
</div>
`;
}
return `
<div class="task-log-row${long ? ' is-long' : ''}${_isRunning ? ' is-running' : ''}" data-kind="${_escHtml(entry.kind)}" data-entry-idx="${entryIdx}" style="${styleVars}">
<div class="task-log-row-head">
${statusDot}
<span class="task-log-name">${_escHtml(entry.taskName)}</span>
${repeatBadge}
<span style="flex:1"></span>
${rightHtml}
</div>
${_isRunning ? '' : `<div class="task-log-row-body">${resultHtml}</div>`}
${promptHtml}
<div class="task-log-row-actions">
${long ? '<button class="task-log-row-toggle" type="button">Show more</button>' : '<span></span>'}
${actionBtn}
</div>
</div>
`;
}
function _escHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ---- Main view ----
// Tasks list view state — search query + active category tag + select mode.
let _taskSearch = '';
let _taskFilter = null;
let _taskSort = 'recent';
let _taskSelectMode = false;
const _taskSelected = new Set();
async function _aiDraftTask(inputEl, btnEl) {
const desc = (inputEl.value || '').trim();
if (!desc) { inputEl.focus(); return; }
const origHtml = btnEl.innerHTML;
btnEl.disabled = true;
btnEl.classList.add('spinning');
btnEl.textContent = '';
// Match the Tidy buttons' silent whirlpool spinner.
const _sp = spinnerModule.create('', 'clean', 'whirlpool');
const _spEl = _sp.createElement();
_spEl.style.position = 'relative';
_spEl.style.top = '1px';
btnEl.appendChild(_spEl);
_sp.start();
try {
const res = await fetch(`${API_BASE}/api/tasks/parse`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: desc }),
});
const data = await res.json();
if (!data.success || !data.draft) {
if (uiModule) uiModule.showError(data.message || 'Could not draft task');
return;
}
const draft = data.draft;
// The form treats scheduled_time as UTC (it converts UTC→local for the
// picker). The AI returns LOCAL time, so convert local→UTC here for the
// round-trip to land on the intended local time.
if (draft.scheduled_time) {
try { draft.scheduled_time = _localTimeToUtc(draft.scheduled_time); } catch (_) {}
}
// Pass the draft as a synthetic "existing" (no id) → form pre-fills every
// field but still creates via POST on save.
_showForm(draft, draft.task_type, draft.trigger_type || 'schedule');
} catch (e) {
if (uiModule) uiModule.showError('AI draft failed: ' + (e.message || e));
} finally {
try { _sp.stop(); } catch (_) {}
btnEl.classList.remove('spinning');
btnEl.disabled = false;
btnEl.innerHTML = origHtml;
}
}
function _renderMainView() {
const modal = document.getElementById('tasks-modal');
if (!modal) return;
const body = modal.querySelector('.modal-body');
if (!body) return;
body.innerHTML = `
<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative;top:-2px;">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
<h2 style="margin:0;padding:0;line-height:1;position:relative;top:-4px;">Ongoing Tasks <span id="tasks-head-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
<button class="memory-toolbar-btn" id="tasks-pause-all-btn" title="Pause all active tasks" style="margin-left:auto;">Pause all</button>
</div>
<p class="memory-desc" style="position:relative;top:-4px;">Scheduled prompts and actions that run automatically. Results appear in a dedicated session.</p>
<div class="memory-toolbar">
<div class="memory-category-filters" style="display:flex;align-items:center;gap:6px;">
<select class="memory-sort-select" id="tasks-sort" style="position:relative;top:-4px;width:86px;font-size:11px;height:24px;">
<option value="recent">Recent</option>
<option value="name">AZ</option>
<option value="status">Status</option>
</select>
<button class="memory-toolbar-btn" id="tasks-select-btn" title="Select tasks" style="position:relative;top:-7px;">Select</button>
</div>
<input type="text" id="tasks-search" placeholder="Search tasks…" class="memory-search-input" value="${_esc(_taskSearch)}" style="position:relative;top:-4px;" />
</div>
<div id="tasks-bulk-bar" class="memory-bulk-bar${_taskSelectMode ? '' : ' hidden'}" style="position:relative;top:-4px;">
<label class="memory-bulk-check-all" style="position:relative;top:0px;"><input type="checkbox" id="tasks-select-all" /> All</label>
<span id="tasks-selected-count">0 Selected</span>
<button id="tasks-bulk-delete" class="memory-toolbar-btn danger" style="position:relative;top:-2px;" disabled><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
<button id="tasks-bulk-cancel" class="memory-toolbar-btn" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div id="tasks-filter-chips" class="tasks-activity-filters" style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px;position:relative;top:-4px;"></div>
<div id="tasks-list" class="memory-list" style="flex:1;gap:4px;position:relative;top:-4px;"></div>
</div>
`;
const searchEl = document.getElementById('tasks-search');
if (searchEl) searchEl.addEventListener('input', () => { _taskSearch = searchEl.value; _renderList(); });
const sortEl = document.getElementById('tasks-sort');
if (sortEl) { sortEl.value = _taskSort; sortEl.addEventListener('change', () => { _taskSort = sortEl.value; _renderList(); }); }
const selectBtn = document.getElementById('tasks-select-btn');
if (selectBtn) {
selectBtn.classList.toggle('active', _taskSelectMode);
selectBtn.addEventListener('click', () => _taskSelectMode ? _taskExitSelect() : _taskEnterSelect());
}
document.getElementById('tasks-pause-all-btn')?.addEventListener('click', () => _doToggleAll());
document.getElementById('tasks-select-all')?.addEventListener('change', _taskToggleSelectAll);
document.getElementById('tasks-bulk-cancel')?.addEventListener('click', _taskExitSelect);
document.getElementById('tasks-bulk-delete')?.addEventListener('click', _taskBulkDelete);
_renderList();
_syncPauseAllButton();
// Lazy-load action descriptions so the list can show them under each
// action task. Re-render once they arrive (no-op if already cached).
if (!_builtinActions) {
_fetchActions().then(() => {
if (document.getElementById('tasks-list')) _renderList();
});
}
}
// ---- Modal ----
export function openTasks(focusId) {
if (_open) {
// Already open — just focus the requested task.
if (focusId) _focusTask(focusId);
return;
}
_pendingFocusTaskId = focusId || null;
_open = true;
_tasksCascadeNext = true;
_viewingRuns = null;
_outputTargets = null; // refresh available targets
_builtinActions = null;
_triggerEvents = null;
const modal = document.createElement('div');
modal.className = 'modal';
modal.id = 'tasks-modal';
modal.innerHTML = `
<div class="modal-content tasks-modal-content">
<div class="modal-header">
<h4 style="position:relative;top:-2px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3L2 6"/><path d="M22 6l-3-3"/></svg>Tasks</h4>
<span style="flex:1"></span>
<button class="close-btn" id="tasks-close">✖</button>
</div>
<div class="memory-tabs tasks-tabs" role="tablist">
<button class="memory-tab tasks-tab active" data-tab="tasks" role="tab" aria-selected="true">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
Tasks <span id="tasks-tab-count" class="memory-count" style="font-size:0.8em;opacity:0.6;font-weight:normal;margin-left:4px">0</span>
</button>
<button class="memory-tab tasks-tab" data-tab="activity" role="tab" aria-selected="false">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Activity
</button>
<button class="memory-tab tasks-tab" data-tab="new" role="tab" aria-selected="false">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add
</button>
</div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px;overflow:hidden;"></div>
<div class="tasks-clock" id="tasks-clock"></div>
</div>
`;
document.body.appendChild(modal);
// Tab routing
modal.querySelectorAll('.tasks-tab').forEach(btn => {
btn.addEventListener('click', () => _switchTab(btn.dataset.tab));
});
// Live clock
function _tickClock() {
const el = document.getElementById('tasks-clock');
if (!el) return;
const now = new Date();
const day = now.toLocaleDateString([], { weekday: 'long' });
const date = now.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
const local = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
el.textContent = `${day}, ${date} · ${local}`;
}
_tickClock();
_clockInterval = setInterval(_tickClock, 1000);
// Make draggable — shared helper handles drag + L/R dock + (none) fs.
{
const content = modal.querySelector('.modal-content');
const header = modal.querySelector('.modal-header');
if (content && header) {
makeWindowDraggable(modal, { content, header });
}
}
// Events
document.getElementById('tasks-close').addEventListener('click', closeTasks);
// "Pause all" + "Select" live in the main-view sub-header now (wired in _renderMainView).
modal.addEventListener('click', (e) => {
if (uiModule.isTouchInsideModal()) return;
if (e.target === modal) closeTasks();
});
_escHandler = (e) => {
if (e.key === 'Escape') {
if (_viewingRuns) {
_viewingRuns = null;
_renderMainView();
return;
}
// If we're on the "Add" tab inside the new-task form (preset already
// picked), step back to the preset picker instead of closing the modal.
// Detect by: Add tab active + the form's name input is mounted.
const _modal = document.getElementById('tasks-modal');
const _addActive = _modal?.querySelector('.tasks-tab.active[data-tab="new"]');
const _formMounted = _modal?.querySelector('#task-form-name');
if (_addActive && _formMounted) {
_showPresetPicker();
return;
}
closeTasks();
}
};
document.addEventListener('keydown', _escHandler);
// Paint the scaffolding immediately so the modal-enter animation reveals a
// populated shell (header/search/sort/empty list with a spinner row) instead
// of an empty modal-body that fills in after the fetch resolves — that delay
// was visible as a "flicker" right after opening.
_activeTab = 'tasks';
_switchTab('tasks');
_fetchTasks().then(() => {
// Re-render so the list swaps the Loading row for real cards.
_renderList();
_syncPauseAllButton();
if (_pendingFocusTaskId) {
_focusTask(_pendingFocusTaskId);
_pendingFocusTaskId = null;
}
_runFirstOpenOnboarding();
});
}
let _pendingFocusTaskId = null;
// Scroll to + briefly highlight a task card by id. Used by the chat
// anchor-link delegate ([Name](#task-<id>)).
function _focusTask(taskId) {
if (!taskId) return;
// Find the task card with this id and scroll-into-view + flash it. Backend
// task IDs are UUIDs so the unescaped selector is safe in practice; if that
// changes, swap to `[data-id="${CSS.escape(taskId)}"]`.
setTimeout(() => {
const card = document.querySelector(`.task-card[data-id="${taskId}"], [data-id="${taskId}"]`);
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('task-card-flash');
setTimeout(() => card.classList.remove('task-card-flash'), 2000);
}, 150);
}
export function closeTasks() {
if (!_open) return;
_open = false;
_viewingRuns = null;
const modal = document.getElementById('tasks-modal');
if (modal) {
const content = modal.querySelector('.modal-content');
if (content) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => modal.remove(), { once: true });
setTimeout(() => { if (modal.parentElement) modal.remove(); }, 250);
} else {
modal.remove();
}
}
if (_escHandler) {
document.removeEventListener('keydown', _escHandler);
_escHandler = null;
}
if (_clockInterval) {
clearInterval(_clockInterval);
_clockInterval = null;
}
// Detach the form-Esc capture listener if it survived (e.g. user closed the
// modal from the X / outside-click while the form was open).
if (window._tasksFormEsc) {
document.removeEventListener('keydown', window._tasksFormEsc, true);
window._tasksFormEsc = null;
}
}
export function isTasksOpen() { return _open; }
// ---- Task run notifications polling ----
let _notifInterval = null;
async function _pollTaskNotifications() {
try {
const res = await fetch(`${API_BASE}/api/tasks/notifications`, { credentials: 'same-origin' });
if (!res.ok) return;
const data = await res.json();
const notes = data.notifications || [];
for (const n of notes) {
const ok = n.status === 'success';
// Tasks with output_target='notification' carry the result text in `body`
// — show it as a real browser Notification (richer than a toast). Falls
// back to a toast when permission is denied or unavailable.
if (ok && n.body) {
const title = n.task_name || 'Task';
let fired = false;
try {
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
new Notification(title, { body: n.body, tag: 'task-' + (n.task_id || title), icon: '/static/favicon.ico' });
fired = true;
}
} catch (_) {}
if (!fired && uiModule) uiModule.showToast(title + ': ' + n.body.slice(0, 140), { duration: 7000 });
continue;
}
const msg = `Task ${ok ? 'finished' : 'failed'}: ${n.task_name}`;
if (!uiModule) continue;
if (ok) uiModule.showToast(msg, { duration: 5000 });
else uiModule.showError(msg);
}
} catch (e) {
// Silently ignore — server may be unreachable
}
}
function startNotificationPolling() {
if (_notifInterval) return;
_notifInterval = setInterval(_pollTaskNotifications, 30000);
}
function stopNotificationPolling() {
if (_notifInterval) {
clearInterval(_notifInterval);
_notifInterval = null;
}
}
// Start polling on module load
startNotificationPolling();
const tasksModule = { openTasks, closeTasks, isTasksOpen, startNotificationPolling, stopNotificationPolling };
export default tasksModule;
window.tasksModule = tasksModule;