/** * 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 { sortModelIds } from './modelSort.js'; import { ordinalSuffix } from './util/ordinal.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`, { 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 _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 || '', }), }); } 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' }; 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: '', 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 `${path}`; } const _MODEL_BACKED_ACTIONS = new Set([ 'summarize_emails', 'draft_email_replies', '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', 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) 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); } const _TASK_CACHE_LABELS = { summarize_emails: 'email summaries', draft_email_replies: 'AI reply drafts', 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' ? `paused` : task.status === 'active' ? `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 (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: '', 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); // 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 = 'task-status-badge task-run-now-badge task-card-run-btn'; runBtn.title = 'Run now'; runBtn.style.cssText = 'position:relative;top:1px;margin-right:4px;'; runBtn.innerHTML = 'Run'; 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); 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);'; 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 = `${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); } 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 = `${item.icon}${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(); 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) => `${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?'; 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; if (sel.value !== 'check_email_urgency') { extra.innerHTML = ''; return; } extra.innerHTML = `
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.
`; 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 = `
`; // 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 = ` `; _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 = `
POST this URL from any external service to trigger the task. No auth needed.
`; document.getElementById('task-form-webhook-copy')?.addEventListener('click', () => { navigator.clipboard.writeText(url); if (uiModule) uiModule.showToast('Copied'); }); } else { triggerOpts.innerHTML = '
Webhook URL will be generated when the task is saved.
'; } } } 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 = sortModelIds([...(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 = `
${_esc(taskName)} — Run history
`; if (runs.length === 0) { html += '
No runs yet.
'; } else { html += '
'; for (const run of runs) { const statusClass = run.status === 'success' ? 'task-run-success' : run.status === 'error' ? 'task-run-error' : 'task-run-running'; html += `
${_statusDot(run.status === 'success' ? 'active' : run.status)} ${run.status} ${run.model ? `${_esc(run.model.split('/').pop())}` : ''} ${run.started_at ? _absoluteTime(run.started_at) : ''}
${_esc(run.result ? (run.result.length > 300 ? run.result.slice(0, 300) + '…' : run.result) : run.error || '—')}
`; } html += '
'; } 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 _doClearTaskCache(id, label = 'cache') { const ok = uiModule?.styledConfirm ? await uiModule.styledConfirm(`Clear cached ${label} for this task?`, { confirmText: 'Clear' }) : confirm(`Clear cached ${label} for this task?`); if (!ok) return; try { const res = await fetch(`${API_BASE}/api/tasks/${encodeURIComponent(id)}/clear-cache`, { method: 'POST', credentials: 'same-origin', }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`); const n = Object.values(data.cleared || {}).reduce((a, b) => a + Number(b || 0), 0) + Number(data.files || 0); if (uiModule) uiModule.showToast(`Cleared ${label}${n ? ` (${n})` : ''}`); } catch (e) { if (uiModule) uiModule.showError(`Clear cache failed: ${e.message || e}`); } } 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 = ''; const playIco = ''; 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 = `

Activity

Recent task runs across all scheduled tasks.

`; document.getElementById('tasks-activity-refresh').addEventListener('click', _renderActivityView); // 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:' | '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 = '
No matching activity.
'; 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 = ``; html += cats.map(c => `` ).join(''); if (hasErrors) { html += ``; } if (notifCount) { html += ``; } 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(); }); const _actList = document.getElementById('tasks-activity-list'); if (_activityEntries.length) { _buildChips(); _applyFilter(); } else if (_actList) { _actList.appendChild(spinnerModule.createLoadingRow('Loading…')); } 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 = '
No activity yet. Scheduled tasks will log here once they run.
'; 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, action: r.action || '', 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 = `
Failed to load activity: ${_escHtml(e.message || String(e))}
`; } } 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 row to toggle expand. // Buttons inside still get their own handlers via stopPropagation. if (!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-stop')?.addEventListener('click', async (e) => { e.stopPropagation(); const idx = parseInt(row.dataset.entryIdx, 10); const entry = _activityEntries[idx]; if (!entry?.taskId) return; try { await _stopTask(entry.taskId); uiModule.showToast('Task stopped'); _renderActivityView(); } catch (err) { uiModule.showError(err.message || 'Failed to stop task'); } }); row.querySelector('.task-log-run-again')?.addEventListener('click', (e) => { e.stopPropagation(); const idx = parseInt(row.dataset.entryIdx, 10); const entry = _activityEntries[idx]; if (entry?.taskId) _doRunNow(entry.taskId); }); 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'); } }); row.querySelector('.task-log-clear-cache')?.addEventListener('click', (e) => { e.stopPropagation(); const idx = parseInt(row.dataset.entryIdx, 10); const entry = _activityEntries[idx]; if (entry?.taskId) _doClearTaskCache(entry.taskId, _taskClearCacheLabel(entry)); }); }); } // 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 ? `+${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') { status = 'error'; } else if (entry.status === 'success') { status = 'ok'; } else { status = _classifyResult(entry.result); } const statusDot = ``; // 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 '); // 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
chunks, leave intact const codeParts = seg.split(/()/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 ? `
Prompt
${_escHtml(entry.prompt)}
` : ''; const hue = _categoryHue(entry.taskName, entry.kind); // 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 `
${statusDot} ${_taskIcon({ action: entry.action, task_type: entry.kind })} ${_escHtml(entry.taskName)}${_taskAiMark(entry)} ${repeatBadge} skipped${reason ? ' — ' + _escHtml(reason) : ''} ${_escHtml(tsLabel)}
`; } return `
${statusDot} ${_taskIcon({ action: entry.action, task_type: entry.kind })} ${_escHtml(entry.taskName)}${_taskAiMark(entry)} ${repeatBadge} ${rightHtml}
${(_isRunning && !hasRunningProgress) ? '' : `
${resultHtml}
`} ${promptHtml}
${long ? '' : ''} ${actionBtn}
`; } function _escHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } // ---- 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 = `

Ongoing Tasks

Scheduled prompts and actions that run automatically. Results appear in a dedicated session.

0 Selected
`; 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 = ` `; 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-)). 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;