/**
* 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';
import { topPortalZ } from './toolWindowZOrder.js';
import { sortModelIds } from './modelSort.js';
import { ordinalSuffix } from './util/ordinal.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.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;
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;
}
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 || '',
}),
});
}
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 _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 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);
}
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 (_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.style.position = 'relative';
menuBtn.style.top = '4px';
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: 'Delete', icon: '', action: () => _doDelete(task.id), danger: true });
_showTaskDropdown(menuBtn, items);
});
actionsWrap.appendChild(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);
const statusPill = titleRow.querySelector('[data-task-status-action]');
if (statusPill) {
statusPill.addEventListener('click', async (e) => {
e.stopPropagation();
if (statusPill.dataset.taskStatusAction === 'pause') await _doPause(task.id);
else await _doResume(task.id);
});
}
// 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);position:relative;';
const detailActions = document.createElement('div');
detailActions.style.cssText = 'display:flex;justify-content:flex-end;gap:6px;margin-top:7px;';
if (task.status !== 'completed') {
const runBtn = document.createElement('button');
runBtn.className = 'memory-toolbar-btn task-detail-run-btn';
runBtn.title = 'Run now';
runBtn.innerHTML = 'Run';
runBtn.addEventListener('click', (e) => {
e.stopPropagation();
_doRunNow(task.id);
});
detailActions.appendChild(runBtn);
}
const editBtn = document.createElement('button');
editBtn.className = 'memory-toolbar-btn task-detail-edit-btn';
editBtn.title = 'Edit task';
editBtn.innerHTML = 'Edit';
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
_showForm(task);
});
detailActions.appendChild(editBtn);
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' || task.last_run_status === 'failed';
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 = `${isErr ? '✗' : '✓'}${_esc(prev) || (isErr ? 'Failed (no detail)' : 'Success (no output)')}`;
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);
}
detail.appendChild(detailActions);
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) {
const existing = document.querySelector('.task-dropdown');
if (existing && existing._anchor === anchor) {
if (typeof existing._dismiss === 'function') existing._dismiss();
else existing.remove();
return;
}
document.querySelectorAll('.task-dropdown').forEach(d => {
if (typeof d._dismiss === 'function') d._dismiss();
else dismissOrRemove(d);
});
const dd = document.createElement('div');
dd.className = 'task-dropdown';
dd._anchor = anchor;
dd.style.cssText = `position:fixed;z-index:${topPortalZ()};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 = `${item.label}`;
} 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(); close(); item.action(); });
dd.appendChild(btn);
});
document.body.appendChild(dd);
// Sit above the currently-raised tool modal at any stack depth (#4720): the
// modal bring-to-front counter climbs unbounded, so a hardcoded z eventually
// loses. topPortalZ() derives the value from the live tool-window stack.
dd.style.zIndex = String(topPortalZ());
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 = bindMenuDismiss(dd, () => { dd.remove(); }, (ev) => {
// 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) — treat as inside.
if (performance.now() - openedAt < 250) return false;
return !dd.contains(ev.target);
});
dd._dismiss = () => {
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) => ``;
if (p.taskType === 'research') return wrap('');
if (p.taskType === 'action') return wrap(''); // sparkle
if (p.triggerType === 'webhook') return wrap(''); // link
if (p.triggerType === 'event') return wrap(''); // activity pulse
return wrap(''); // 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 = '
';
html += '
Add Task
';
html += '
Describe a task for the AI to draft, or pick a type below to set one up manually.
';
// flex-wrap + min-width:0 on the input lets the row collapse cleanly
// on narrow modal widths instead of pushing the AI button past the
// right edge. margin-left:-4px nudges the compose row 4px into the
// description bar above so the input lines up with it visually.
html += '
'
+ ''
+ ''
+ '
';
html += '
';
_TASK_PRESETS.forEach((p, i) => {
html += ``;
});
html += '
';
html += '
';
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 = `
${existing?.id ? 'Edit Task' : 'New Task'}
${existing?.id ? 'Update this task’s schedule, prompt, and output.' : 'Configure a prompt, research, or action to run automatically.'}
`;
// --- 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?';
const _personaOpts = [
['', 'Default (no persona)'],
['socrates', 'Socrates'],
['razor', 'Razor'],
['nietzsche', 'Nietzsche'],
['spark', 'Spark'],
['odysseus', 'Odysseus'],
];
const _curPersona = (existing?.character_id || '').toLowerCase();
const _personaOptsHtml = _personaOpts.map(([v, label]) =>
``).join('');
typeOpts.innerHTML = `
`;
} else {
typeOpts.innerHTML = `
`;
const syncActionExtra = async () => {
const sel = document.getElementById('task-form-action');
const extra = document.getElementById('task-form-action-extra');
if (!sel || !extra) return;
const action = sel.value;
if (!_EMAIL_ACCOUNT_ACTIONS.has(action)) {
extra.innerHTML = '';
return;
}
extra.innerHTML = '';
await _renderEmailActionOptions(action, existing, extra);
if (action === 'check_email_urgency') {
extra.insertAdjacentHTML('beforeend', `
Pause/resume and schedule are controlled by this task. It tags work, personal, urgent, action-needed, finance, legal, travel, newsletter, marketing, spam, and related mail categories. Urgent/reply-soon emails use your reminder settings.
`;
}
function _wireCompletedPreviewRows(list) {
list.querySelectorAll('.task-completed-open-chat').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const row = btn.closest('.task-completed-preview-row');
const idx = parseInt(row?.dataset.entryIdx || '-1', 10);
const entry = _activityEntries[idx];
if (entry) _openResultInChat(entry);
});
});
list.querySelectorAll('.task-completed-copy-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const row = btn.closest('.task-completed-preview-row');
const idx = parseInt(row?.dataset.entryIdx || '-1', 10);
const entry = _activityEntries[idx];
if (!entry) return;
try {
uiModule.copyToClipboard((entry.result || '').trim());
uiModule.showToast('Output 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, opts = {}) {
// 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
? `+${entry.repeatCount - 1} repeats`
: '';
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' || entry.status === 'failed') {
status = 'error';
} else if (entry.status === 'success') {
status = 'ok';
} else {
status = _classifyResult(entry.result);
}
const statusDot = ``;
const failedTag = status === 'error'
? '(failed)'
: '';
// Render the result through markdown so code blocks, lists, links look right.
let resultHtml;
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 && !(entry.result || '').trim()) {
resultHtml = '';
} else {
try {
resultHtml = markdownModule.processWithThinking(markdownModule.squashOutsideCode(entry.result || ''));
} catch {
resultHtml = `
${_escHtml(entry.result || '')}
`;
}
}
// 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 " message" instead of a wall of brackets.
// Skip
/ blocks: bash output / tracebacks / numbered lists often
// contain "\n[N] ..." sequences that the prefix regex would otherwise mangle.
{
const tagRe = /(^|
| |\n)\[([^\]\n<>]{1,40})\]\s*/g;
const replaceTags = (s) => s.replace(tagRe, '$1$2 ');
// Split on whole
...
blocks (greedy match per block); only
// transform the outside-of-pre segments. Then do the same for any stray
// inline ... spans inside the surviving outside text.
const parts = resultHtml.split(/(
)/i);
resultHtml = parts.map((seg, i) => {
if (i % 2 === 1) return seg; // odd indices = the
`
: '';
const hue = status === 'error' ? 0 : _categoryHue(entry.taskName, entry.kind);
const rowStatusClass = ` task-log-row-${status}`;
// CSS vars feed the colored title + accent stripe.
const styleVars = `--cat-hue:${hue};`;
const _runningPlaceholder = /^(Starting…|Starting\.\.\.|_Running…_|_Running\.\.\._|_Queued\b)/i.test((entry.result || '').trim());
const hasResult = !!(entry.result && entry.result.trim() && entry.status !== 'running' && entry.status !== 'queued');
const hasRunningProgress = !!(entry.result && entry.result.trim() && !_runningPlaceholder && (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 = ``;
if (entry.kind === 'research' && entry.researchId) {
actionBtn += ``;
}
} else if (hasResult) {
actionBtn = ``;
}
const clearLabel = _taskClearCacheLabel(entry);
if (hasResult && clearLabel && entry.taskId) {
actionBtn += ``;
}
if (hasResult && entry.taskId) {
actionBtn += ``;
}
// 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';
// 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 stale = !isQueued && (Date.now() - startMs) > 30 * 60 * 1000;
const label = isQueued ? 'Queued' : stale ? 'Still running' : 'Running';
const elapsedInit = isQueued ? '' : `${_fmtElapsed(Date.now() - startMs)}`;
const forceBtn = isQueued && entry.taskId ? `` : '';
const stopBtn = entry.taskId ? `` : '';
rightHtml = `${label}${elapsedInit}${forceBtn}${stopBtn}`;
} else {
rightHtml = `${_escHtml(tsLabel)}`;
}
// 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 `