/**
* Tasks Module — scheduled recurring LLM prompts.
*/
import uiModule from './ui.js?v=20260908weekhoverfix1';
import markdownModule from './markdown.js';
import * as spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { topPortalZ } from './toolWindowZOrder.js';
import { sortModelIds } from './modelSort.js';
import { ordinalSuffix } from './util/ordinal.js';
import {
bindMenuDismiss,
dismissOrRemove,
bindExpandedCardDismiss,
unbindExpandedCardDismiss,
} from './escMenuStack.js';
import { getSettings, invalidateSettings } from './appConfig.js';
import { orderActionMenuItems, actionMenuRank, SELECT_MENU_ICON } from './actionMenuOrder.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;
let _taskFailurePending = false;
let _taskCompletionPending = false;
let _taskBulkDeleting = false;
let _pendingFocusAction = null;
const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
function _setTaskFailurePending(active) {
_taskFailurePending = !!active;
document.getElementById('tool-tasks-btn')?.classList.toggle('task-failure-pending', _taskFailurePending);
document.getElementById('rail-tasks')?.classList.toggle('task-failure-pending', _taskFailurePending);
}
function _setTaskCompletionPending(active) {
_taskCompletionPending = !!active;
document.getElementById('tool-tasks-btn')?.classList.toggle('task-completion-pending', _taskCompletionPending);
document.getElementById('rail-tasks')?.classList.toggle('task-completion-pending', _taskCompletionPending);
}
// ---- API ----
async function _fetchTasks() {
try {
const res = await fetch(`${API_BASE}/api/tasks`, { 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));
}
function _setTaskCardsDeleting(ids, active) {
for (const id of ids) {
const card = _taskCardById(id);
if (!card) continue;
card.classList.toggle('task-card-deleting', !!active);
const existing = card.querySelector('.task-card-delete-busy');
if (!active) {
existing?.remove();
continue;
}
if (!existing) {
const badge = document.createElement('span');
badge.className = 'task-card-delete-busy';
badge.innerHTML = 'Deleting';
card.appendChild(badge);
}
}
}
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 _stopTask(id) {
const res = await fetch(`${API_BASE}/api/tasks/${id}/stop`, {
method: 'POST',
credentials: 'same-origin',
});
if (!res.ok) {
let msg = `Failed to stop task (${res.status})`;
try {
const data = await res.json();
if (data && data.detail) msg = data.detail;
} catch (_) {}
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;
}
async function _fetchUrgentEmailSettings() {
try {
return await getSettings();
} catch (e) {
return { urgent_email_prompt: '' };
}
}
async function _saveUrgentEmailSettings(prompt) {
try {
await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
urgent_email_prompt: prompt || '',
}),
});
} finally {
// The shared snapshot still carries the old prompt — drop it so the next
// read (here or in any other module) sees what was just written. In a
// `finally` because a request that throws on the way back may still have
// been applied.
invalidateSettings();
}
}
const _EMAIL_ACCOUNT_ACTIONS = new Set([
'summarize_emails',
'draft_email_replies',
'email_auto_translate',
'extract_email_events',
'check_email_urgency',
]);
let _emailAccounts = null;
async function _fetchEmailAccountsForTasks() {
if (_emailAccounts) return _emailAccounts;
try {
const res = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
const data = await res.json();
_emailAccounts = Array.isArray(data.accounts) ? data.accounts : [];
} catch (e) {
_emailAccounts = [];
}
return _emailAccounts;
}
function _taskPromptConfig(prompt) {
const raw = (prompt || '').trim();
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (_) {
const cfg = {};
for (const line of raw.split(/\r?\n/)) {
const idx = line.indexOf('=');
if (idx <= 0) continue;
const key = line.slice(0, idx).trim();
const val = line.slice(idx + 1).trim();
if (key) cfg[key] = val;
}
return cfg;
}
}
function _parseTaskEmailOutputTarget(output) {
const raw = String(output || '').trim();
if (!raw) return { enabled: false, to: '', accountId: '' };
if (raw === 'email') return { enabled: true, to: '', accountId: '' };
if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(raw)) return { enabled: true, to: raw, accountId: '' };
if (!raw.startsWith('email:')) return { enabled: false, to: '', accountId: '' };
let payload = raw.slice('email:'.length).trim();
let accountId = '';
const marker = '|account=';
const markerIdx = payload.indexOf(marker);
if (markerIdx >= 0) {
accountId = payload.slice(markerIdx + marker.length).trim();
payload = payload.slice(0, markerIdx).trim();
}
return {
enabled: true,
to: payload && payload !== 'self' ? payload : '',
accountId,
};
}
function _buildTaskEmailOutputTarget(to, accountId) {
const cleanTo = String(to || '').trim();
const cleanAccount = String(accountId || '').trim();
const base = `email:${cleanTo || 'self'}`;
return cleanAccount ? `${base}|account=${cleanAccount}` : (cleanTo ? base : 'email');
}
async function _renderEmailActionOptions(action, existing, extra) {
if (!_EMAIL_ACCOUNT_ACTIONS.has(action)) return;
const accounts = (await _fetchEmailAccountsForTasks()).filter(a => a && a.enabled !== false);
const cfg = _taskPromptConfig(existing?.prompt || '');
const current = String(cfg.account_id || cfg.email_account_id || '');
const options = [
``,
...accounts.map(a => {
const id = String(a.id || '');
const label = a.name || a.from_address || a.imap_user || id.slice(0, 8);
const suffix = a.is_default ? ' (default)' : '';
return ``;
}),
].join('');
extra.insertAdjacentHTML('afterbegin', `
`);
}
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 = ordinalSuffix(d);
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', failed: '#f44336' };
const c = colors[status] || '#888';
return ``;
}
const _TASK_ICONS = {
// Chats
tidy_sessions: '',
// Documents
tidy_documents: '',
// Memory (brain)
consolidate_memory: '',
// Research (magnifying glass)
tidy_research: '',
// Calendar
tidy_calendar: '',
// Email
summarize_emails: '',
draft_email_replies: '',
email_auto_translate:'',
extract_email_events:'',
classify_events: '',
learn_sender_signatures:'',
check_email_urgency: '',
// Skills
test_skills: '',
audit_skills: '',
// Assistant
daily_brief: '',
// Generic action fallback (gear)
_action_default: '',
// LLM task fallback (chat bubble)
_llm_default: '',
};
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 ``;
}
const _MODEL_BACKED_ACTIONS = new Set([
'summarize_emails',
'draft_email_replies',
'email_auto_translate',
'extract_email_events',
'classify_events',
'learn_sender_signatures',
'check_email_urgency',
'test_skills',
'audit_skills',
'consolidate_memory',
]);
function _taskAiMark(task) {
const kind = task?.task_type || task?.kind || '';
const action = task?.action || '';
const aiAction = _MODEL_BACKED_ACTIONS.has(action);
if (!(kind === 'llm' || kind === 'research' || task?.model || task?.endpointUrl || aiAction)) return '';
return '';
}
// ---- 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',
email_auto_translate: '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',
cookbook_serve: 'Cookbook',
};
// Cookbook serves listed FIRST so a just-saved schedule shows at the
// top instead of scrolling off the bottom of the list. The remaining
// order is preserved for backwards-compatibility with users who've
// learned where things are.
const _CATEGORY_ORDER = ['Cookbook', 'Other', 'Calendar', 'Email', 'Chats', 'Documents', 'Memory', 'Research', 'Skills', 'Assistant', 'System'];
const _CATEGORY_ICONS = {
Calendar: '',
Email: '',
Chats: '',
Documents: '',
Memory: '',
Research: '',
Skills: '',
Assistant: '',
System: '',
// Cookbook icon — matches the recipe-book glyph used on the sidebar.
Cookbook: '',
Other: '',
};
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 _taskEnterSelectWith(taskId) {
_taskSelectMode = true;
_taskSelected.clear();
_taskSelected.add(taskId);
document.getElementById('tasks-bulk-bar')?.classList.remove('hidden');
const selectButton = document.getElementById('tasks-select-btn');
if (selectButton) { selectButton.classList.add('active'); selectButton.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 || _taskBulkDeleting) 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;
_taskBulkDeleting = true;
const countEl = document.getElementById('tasks-selected-count');
const deleteBtn = document.getElementById('tasks-bulk-delete');
const cancelBtn = document.getElementById('tasks-bulk-cancel');
const selectAll = document.getElementById('tasks-select-all');
const originalDeleteHtml = deleteBtn?.innerHTML || '';
let busySpinner = null;
if (deleteBtn) {
deleteBtn.disabled = true;
deleteBtn.classList.add('tasks-bulk-loading');
deleteBtn.innerHTML = 'Deleting';
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
const spEl = busySpinner.createElement();
spEl.classList.add('tasks-bulk-whirlpool');
deleteBtn.appendChild(spEl);
busySpinner.start();
}
if (cancelBtn) cancelBtn.disabled = true;
if (selectAll) selectAll.disabled = true;
if (countEl) countEl.textContent = `Deleting 0/${ids.length}…`;
_setTaskCardsDeleting(ids, true);
const deletedIds = [];
let finished = 0;
try {
const results = await Promise.allSettled(ids.map(async (id) => {
try {
await _deleteTask(id);
deletedIds.push(id);
} finally {
finished += 1;
if (countEl) countEl.textContent = `Deleting ${finished}/${ids.length}…`;
}
}));
const failed = results.filter(r => r.status === 'rejected').length;
await _animateTaskRemoval(deletedIds);
if (uiModule) {
const msg = failed
? `Deleted ${deletedIds.length}, failed ${failed}`
: `Deleted ${deletedIds.length} task${deletedIds.length > 1 ? 's' : ''}`;
uiModule.showToast(msg);
}
await _fetchTasks();
} finally {
_setTaskCardsDeleting(ids, false);
if (busySpinner) busySpinner.destroy();
if (deleteBtn) {
deleteBtn.classList.remove('tasks-bulk-loading');
deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML;
}
if (cancelBtn) cancelBtn.disabled = false;
if (selectAll) selectAll.disabled = false;
_taskBulkDeleting = false;
_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 _taskStatus = (t) => String(t?.status || '').toLowerCase();
const activeCount = _tasks.filter(t => _taskStatus(t) === 'active').length;
const pausedCount = _tasks.filter(t => _taskStatus(t) === 'paused').length;
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;
if (_taskStatusFilter && !({ active: activeCount, paused: pausedCount })[_taskStatusFilter]) _taskStatusFilter = null;
bar.innerHTML = '';
bar.style.display = _tasks.length ? '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, kind = 'category') => {
const b = document.createElement('button');
b.className = 'memory-cat-chip task-filter-chip' + (kind === 'status' ? ' task-status-filter-chip' : '') + (active ? ' active' : '');
b.textContent = label;
b.addEventListener('click', () => {
if (kind === 'status') _taskStatusFilter = _taskStatusFilter === value ? null : value;
else {
_taskFilter = value;
if (value === null) _taskStatusFilter = null;
}
_renderList();
});
bar.appendChild(b);
};
mkChip(`all (${_tasks.length})`, null, !_taskFilter && !_taskStatusFilter);
mkChip(`active (${activeCount})`, 'active', _taskStatusFilter === 'active', 'status');
mkChip(`paused (${pausedCount})`, 'paused', _taskStatusFilter === 'paused', 'status');
for (const c of cats) mkChip(`${c} (${counts[c]})`, c, _taskFilter === c);
}
function _renderCompletedTaskStatusShortcuts() {
const bar = document.getElementById('tasks-completed-status-chips');
if (!bar) return;
const statusOf = (t) => String(t?.status || '').toLowerCase();
const activeCount = _tasks.filter(t => statusOf(t) === 'active').length;
const pausedCount = _tasks.filter(t => statusOf(t) === 'paused').length;
if (!_tasks.length) {
bar.innerHTML = '';
bar.style.display = 'none';
return;
}
bar.style.display = 'flex';
const mkShortcut = (label, value) => {
const b = document.createElement('button');
b.className = 'memory-cat-chip task-filter-chip task-status-filter-chip';
b.textContent = label;
b.addEventListener('click', () => {
_taskStatusFilter = value;
_switchTab('tasks');
});
bar.appendChild(b);
};
bar.innerHTML = '';
mkShortcut(`active (${activeCount})`, 'active');
mkShortcut(`paused (${pausedCount})`, 'paused');
}
const _TASK_CACHE_LABELS = {
summarize_emails: 'email summaries',
draft_email_replies: 'AI reply drafts',
email_auto_translate: 'email translations',
extract_email_events: 'email calendar cache',
learn_sender_signatures: 'sender signatures',
check_email_urgency: 'email tags',
};
function _taskClearCacheLabel(taskOrEntry) {
return _TASK_CACHE_LABELS[taskOrEntry?.action || ''] || '';
}
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 = '
No tasks yet. Create one to get started.
';
}
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 (_taskStatusFilter && String(t.status || '').toLowerCase() !== _taskStatusFilter) return false;
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 = 'No matching tasks.
';
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); status pill + chevron/actions (right).
// The status pill replaces the old dot and doubles as pause/resume.
const titleRow = document.createElement('div');
titleRow.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;';
const statusBadge = task.status === 'paused'
? ``
: task.status === 'active'
? ``
: '';
const builtinBadge = task.is_builtin
? `built-in${task.is_modified ? ' · edited' : ''}`
: '';
titleRow.innerHTML = `${_taskIcon(task)}${_esc(task.name)}${_taskAiMark(task)}${builtinBadge}${statusBadge}`;
// ... 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.innerHTML = '';
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
const items = [];
// Run now stays in the kebab too for users coming from muscle-memory /
// mobile long-press. The expanded card also shows it next to Edit.
if (task.status !== 'completed') items.push({ label: 'Run now', icon: '', action: () => _doRunNow(task.id) });
items.push({ label: 'Edit', icon: '', action: () => _showForm(task) });
if (task.status === 'active') items.push({ label: 'Pause', icon: '', action: () => _doPause(task.id) });
else if (task.status === 'paused') items.push({ label: 'Resume', icon: '', action: () => _doResume(task.id) });
items.push({ label: 'History', icon: '', action: () => _showRunHistory(task.id, task.name) });
if (task.is_builtin && task.is_modified) {
items.push({ label: 'Revert to default', icon: '', action: () => _doRevert(task.id) });
}
if (_taskClearCacheLabel(task)) {
items.push({ label: 'Clear cache', icon: '', action: () => _doClearTaskCache(task.id, _taskClearCacheLabel(task)) });
}
items.push({
label: 'Select',
icon: SELECT_MENU_ICON.replace(/^