// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base. Object.assign(TasksManager.prototype, { async retryTask(taskId) { if (!confirm('Are you sure you want to retry this task?')) return; try { const task = this.tasks.find(t => t.id === taskId); if (!task) { throw new Error('Task not found'); } // Create a new task with the same command, linked to the failed one via // `retryOf`. The server keeps the failed task (its log is the failure // record) and stamps it `retriedBy` so the UI can mark it "Retried". const newTask = await this.taskManager.createTask( task.command, task.type, task.app, task.config, { retryOf: task.id } ); // Refresh tasks to show both the new run and the now-"Retried" original, // then jump to the new run and open its live log — the user follows the // retry instead of hunting for it. await this.loadTasks(); if (typeof this.selectTask === 'function') { // Row exists after the re-render; select on the next frame to be safe. requestAnimationFrame(() => this.selectTask(newTask.id)); } if (window.notificationSystem) { // Use the source task's type icon — retrying a backup shows 💾 etc. const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : ''; const customIcon = typeIcon ? `${typeIcon}` : null; window.notificationSystem.show('Task retried — following the new run.', 'success', null, null, null, customIcon); } } catch (error) { console.error('Error retrying task:', error); if (window.notificationSystem) { window.notificationSystem.error(`Failed to retry task: ${error.message}`); } } }, async deleteTask(taskId) { // Get the latest known status before deciding what to do. The server // refuses to delete tasks that are still running or queued (HTTP 409), // and we used to just propagate that as a red banner — but the user is // almost always trying to clean up a row that looked finished, or a // genuinely active task they want gone. Either way the right answer is // "cancel first, then delete", which is what we do here. const cached = (window.taskEventBus && window.taskEventBus.getTask) ? window.taskEventBus.getTask(taskId) : null; let task = cached || (this.tasks && this.tasks.find(t => t.id === taskId)); if (!task && this.taskManager && this.taskManager.getTask) { try { task = await this.taskManager.getTask(taskId); } catch {} } const isActive = task && (task.status === 'running' || task.status === 'queued' || task.status === 'pending'); const confirmed = await this._showDeleteTaskModal(task, isActive); if (!confirmed) return; try { if (isActive) { // Ask the server to cancel. POST /cancel either flips status straight // to `cancelled` (queued -> cancelled is synchronous) or drops a // `.cancel` marker the bash processor picks up within ~1s // (running -> cancelled). We then wait for the terminal status via // SSE before issuing the delete. try { await this.taskManager.cancelTask(taskId); } catch { // If cancel itself failed (e.g. already terminal), fall through // and try the delete anyway. } await this._waitForTaskTerminal(taskId, 15_000); } // Try the polite delete first. If the task is still flagged as // running/queued server-side (e.g. the bash processor is dead and // never picked up the cancel marker), fall back to the force-delete // override so the user isn't stuck with a permanently-undeletable row. try { await this.taskManager.deleteTask(taskId); } catch (err) { const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : ''); if (!looks409) throw err; await this.taskManager.deleteTask(taskId, { force: true }); } // Remove from local array and re-render this.tasks = this.tasks.filter(t => t.id !== taskId); this.renderTasks(); this.updateStats(); this.updateSidebarCounts(); this.generateAppCategories(); if (window.notificationSystem) { // Match the " task " two-line shape used by started/ // completed/failed/cancelled so deletion reads as part of the same // family. Type emoji falls back to 🗑️ since deletion is a 🗑️ action. const { appName, actionTitle, displayName, icon, typeIcon } = this._taskNotificationDescriptor(task); const emoji = typeIcon || '🗑️'; const customIcon = `${emoji}`; const body = displayName ? `${displayName}
${actionTitle} task deleted.` : `${actionTitle || 'Task'} deleted.`; window.notificationSystem.show(body, 'info', appName, null, icon, customIcon); } } catch (error) { console.error('Error deleting task:', error); if (window.notificationSystem) { window.notificationSystem.error(`Failed to delete task: ${error.message}`); } } }, async clearAllTasks() { // Routes to one of two modes depending on whether any rows are ticked. // Both paths share _showClearAllModal — same UX, same modal, different // input list + title. // // On an app detail page (#app-tasks-list present, no #tasks-list) the // universe is that app's tasks only — "Clear All" there must never // reach across every app the way it does on /tasks. const appScope = this._selectionAppScope(); const universe = appScope ? this.tasks.filter(t => t.app === appScope) : this.tasks; const sel = this._selectionSet(); const hasSelection = sel.size > 0; const targetTasks = hasSelection ? universe.filter(t => sel.has(t.id)) : universe; const result = await this._showClearAllModal(targetTasks, hasSelection ? 'selected' : 'all'); if (!result || !result.confirmed) return false; await this.performClearAll({ cancelRunning: result.cancelRunning, targets: targetTasks }); if (hasSelection) { // Drop any ids we just deleted from the selection set (mutating in // place — the set is shared across instances), then refresh the // button label + master-checkbox state. const alive = [...sel].filter(id => this.tasks.find(t => t.id === id)); sel.clear(); alive.forEach(id => sel.add(id)); this._updateSelectionUI(); } return true; }, async performClearAll(opts) { opts = opts || {}; const cancelRunning = !!opts.cancelRunning; // Caller (clearAllTasks) passes the subset to operate on. Falls back // to this.tasks for backward compatibility — old direct callers // continue to mean "everything". const universe = Array.isArray(opts.targets) ? opts.targets : (this.tasks || []); // Show progress notification with the trash type icon in the left slot // (this is conceptually a delete-all action, so 🗑️ matches the row icon). const customIcon = '🗑️'; const progressNotification = window.notificationSystem.show( 'Clearing tasks...', 'info', null, null, null, customIcon ); const isActive = (t) => t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending'); // Partition: which tasks we attempt now, which we skip silently. const targets = universe.filter(t => cancelRunning || !isActive(t)); const skipped = universe.filter(t => !targets.includes(t)); try { // For active tasks (only reached when the toggle is on): cancel first, // wait for terminal status, then delete. Mirrors the single-row // deleteTask flow so the bash processor has a chance to honour the // cancel marker before the DELETE arrives (otherwise we'd 409). await Promise.all(targets.map(async (task) => { if (cancelRunning && isActive(task)) { try { await this.taskManager.cancelTask(task.id); } catch {} await this._waitForTaskTerminal(task.id, 15_000); } try { await this.taskManager.deleteTask(task.id); } catch (err) { const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : ''); if (!looks409) throw err; await this.taskManager.deleteTask(task.id, { force: true }); } })); // Re-sync local state: keep anything we intentionally skipped. const deletedIds = new Set(targets.map(t => t.id)); this.tasks = this.tasks.filter(t => !deletedIds.has(t.id)); this.renderTasks(); this.updateStats(); this.updateSidebarCounts(); this.generateAppCategories(); // renderTasks no-ops on an app page (#tasks-list absent) — rebuild the // per-app list there so the deleted rows actually leave the screen. if (this._selectionAppScope() && window.appTabbedManager && typeof window.appTabbedManager.loadAppTasks === 'function') { try { await window.appTabbedManager.loadAppTasks(); } catch {} } if (progressNotification && progressNotification.remove) { progressNotification.remove(); } if (window.notificationSystem) { const msg = skipped.length > 0 ? `Deleted ${targets.length} tasks (skipped ${skipped.length} still running)` : (targets.length === 1 ? 'Task deleted' : `Deleted ${targets.length} tasks`); window.notificationSystem.show(msg, 'success', null, null, null, customIcon); } } catch (error) { console.error('Error clearing tasks:', error); if (progressNotification && progressNotification.remove) { progressNotification.remove(); } if (window.notificationSystem) { window.notificationSystem.show( `Failed to clear tasks: ${error.message}`, 'error', null, null, null, customIcon ); } } }, // Selection helpers wired by the row + master checkboxes. The same // machinery serves two hosts: the global /tasks page (#tasks-list, // #tasks-clear-btn, #tasks-select-all) and an app detail page's Tasks tab // (#app-tasks-list, #app-tasks-clear-btn, #app-tasks-select-all) — the // resolvers below pick whichever host is actually in the DOM. // // Selection state must be ONE set page-wide. TasksManager is constructed // in several places (system-loader, app-tabbed-manager, …) while the row // checkboxes always write through window.tasksManager — so every reader // and writer resolves to that instance's set. Without this, ticks landed // on one instance and the Delete Selected click read another's empty set, // silently widening the delete to the whole (scoped) list. _selectionSet() { return (window.tasksManager && window.tasksManager.selectedTaskIds) || this.selectedTaskIds; }, _selectionListEl() { return document.getElementById('tasks-list') || document.getElementById('app-tasks-list'); }, // Truthy (the app slug) only when the ACTIVE selection host is an app // page's task list — used to scope Clear All to that app's tasks. _selectionAppScope() { if (document.getElementById('tasks-list')) return null; if (!document.getElementById('app-tasks-list')) return null; return (window.appTabbedManager && window.appTabbedManager.currentApp) || null; }, toggleTaskSelection(taskId, checked) { const sel = this._selectionSet(); if (checked) sel.add(taskId); else sel.delete(taskId); this._updateSelectionUI(); }, toggleSelectAll(checked) { const list = this._selectionListEl(); const sel = this._selectionSet(); if (checked) { // Tick every CURRENTLY VISIBLE row. We use the rendered checkboxes // rather than this.tasks so category-filtered views only select // what the user can see. const boxes = list ? list.querySelectorAll('[data-task-select]') : []; boxes.forEach((cb) => { sel.add(cb.dataset.taskSelect); cb.checked = true; }); } else { sel.clear(); if (list) list.querySelectorAll('[data-task-select]').forEach((cb) => { cb.checked = false; }); } this._updateSelectionUI(); }, // Rewrites the Clear All button label + the master-checkbox indeterminate // state to reflect the current selection. Cheap — only touches a few // DOM nodes, safe to call from any selection-change path. _updateSelectionUI() { const n = this._selectionSet().size; const btn = document.getElementById('tasks-clear-btn') || document.getElementById('app-tasks-clear-btn'); const btnLabel = btn && btn.querySelector('.clear-btn-label'); if (btnLabel) btnLabel.textContent = n > 0 ? `Delete Selected (${n})` : 'Clear All'; if (btn) btn.title = n > 0 ? `Delete ${n} selected task${n === 1 ? '' : 's'}` : 'Clear All Tasks'; // Master checkbox: checked when ALL visible are picked, indeterminate // when SOME are, unchecked when none. const master = document.getElementById('tasks-select-all') || document.getElementById('app-tasks-select-all'); const list = this._selectionListEl(); const visible = list ? list.querySelectorAll('[data-task-select]') : []; if (master) { if (n === 0 || visible.length === 0) { master.checked = false; master.indeterminate = false; } else if (n >= visible.length) { master.checked = true; master.indeterminate = false; } else { master.checked = false; master.indeterminate = true; } } }, // Resolves once the task reaches completed/failed/cancelled, or after the // timeout. Used by deleteTask so a cancel request has time to take effect // before we issue the DELETE that would otherwise 409. _waitForTaskTerminal(taskId, timeoutMs = 15_000) { return new Promise((resolve) => { // Already terminal in the bus cache? No need to wait. const cached = (window.taskEventBus && window.taskEventBus.getTask) ? window.taskEventBus.getTask(taskId) : null; if (cached && (cached.status === 'completed' || cached.status === 'failed' || cached.status === 'cancelled')) { return resolve(cached); } let timer; const cleanup = () => { window.removeEventListener('taskCompleted', onComplete); window.removeEventListener('taskUpdated', onUpdate); if (timer) clearTimeout(timer); }; const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled'; const onComplete = (e) => { const t = e.detail && (e.detail.task || (e.detail.taskId === taskId ? { id: taskId, status: e.detail.status } : null)); const id = (t && t.id) || (e.detail && e.detail.taskId); if (id !== taskId) return; cleanup(); resolve(t); }; const onUpdate = (e) => { const t = e.detail && e.detail.task; if (!t || t.id !== taskId) return; if (isTerminal(t.status)) { cleanup(); resolve(t); } }; window.addEventListener('taskCompleted', onComplete); window.addEventListener('taskUpdated', onUpdate); timer = setTimeout(() => { cleanup(); resolve(null); }, timeoutMs); }); }, });