Landing on /tasks (directly, via a deep link, or from the setup-wizard handoff) now opens the row the visit is actually about: - init() re-reads the URL on every SPA (re)mount, so ?task= deep links work after the first visit instead of using constructor-stale state. - applyInitialSelection() opens the deep-linked task, or — for setup handoffs whose first task the queue has already moved past, and for plain visits with no deep link — the currently running task (else the next queued one). - The selection then follows the queue: when a new task starts running the open panel moves with it, until the user manually toggles a row or switches category (their choice then wins for the visit). - selectTask() is the shared programmatic open: exclusive expand, live log stream for active tasks, smooth scroll into view. Signed-off-by: librelad <librelad@digitalangels.vip> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
260 lines
11 KiB
JavaScript
260 lines
11 KiB
JavaScript
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
|
|
Object.assign(TasksManager.prototype, {
|
|
toggleTaskDetails(taskId) {
|
|
// A manual toggle means the user has taken control of the view — stop
|
|
// auto-following the running task for the rest of this visit.
|
|
this.followRunning = false;
|
|
const details = document.getElementById(`details-${taskId}`);
|
|
const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`);
|
|
|
|
if (details) {
|
|
const isOpen = details.style.display === 'block';
|
|
|
|
// Close all other task details and reset their buttons
|
|
if (isOpen) {
|
|
document.querySelectorAll('.task-details').forEach(otherDetails => {
|
|
if (otherDetails.id !== `details-${taskId}`) {
|
|
otherDetails.style.display = 'none';
|
|
otherDetails.classList.remove('task-details-open');
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('.task-btn.toggle-details').forEach(otherBtn => {
|
|
if (!otherBtn.getAttribute('onclick').includes(taskId)) {
|
|
otherBtn.classList.remove('expanded');
|
|
}
|
|
});
|
|
|
|
// Close current
|
|
details.style.display = 'none';
|
|
details.classList.remove('task-details-open');
|
|
if (toggleBtn) toggleBtn.classList.remove('expanded');
|
|
} else {
|
|
// Open current
|
|
details.style.display = 'block';
|
|
details.classList.add('task-details-open');
|
|
if (toggleBtn) toggleBtn.classList.add('expanded');
|
|
|
|
// Auto-load logs when opened. For active tasks, hand off to the live
|
|
// streamer so SSE chunks keep updating the panel; for terminal tasks
|
|
// a one-shot snapshot is enough.
|
|
const t = this.tasks.find(x => x.id === taskId);
|
|
if (t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending')) {
|
|
this.startLogStreaming(taskId, t);
|
|
} else {
|
|
this.loadTaskLogs(taskId);
|
|
}
|
|
|
|
// Scroll to task
|
|
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
|
|
if (taskElement) {
|
|
taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}
|
|
|
|
// Update URL to include task parameter
|
|
this.updateURL(this.currentCategory, isOpen ? null : taskId);
|
|
this.highlightedTaskId = isOpen ? null : taskId;
|
|
} else {
|
|
// Remove task parameter from URL
|
|
this.updateURL(this.currentCategory);
|
|
this.highlightedTaskId = null;
|
|
}
|
|
},
|
|
// Programmatically open exactly one task row: collapse any other open
|
|
// panel, expand this one, attach the right log view (live stream for
|
|
// active tasks, snapshot for terminal ones) and bring it into view.
|
|
// Used by deep links and the running-task auto-follow — unlike
|
|
// toggleTaskDetails it never closes an already-open target and never
|
|
// touches followRunning. Returns false when the row isn't in the DOM yet.
|
|
selectTask(taskId) {
|
|
const details = document.getElementById(`details-${taskId}`);
|
|
if (!details) return false;
|
|
|
|
document.querySelectorAll('.task-details.task-details-open').forEach(el => {
|
|
if (el.id !== `details-${taskId}`) {
|
|
el.style.display = 'none';
|
|
el.classList.remove('task-details-open');
|
|
}
|
|
});
|
|
document.querySelectorAll('.task-btn.toggle-details.expanded').forEach(btn => {
|
|
if (!(btn.getAttribute('onclick') || '').includes(`'${taskId}'`)) btn.classList.remove('expanded');
|
|
});
|
|
|
|
const wasOpen = details.classList.contains('task-details-open');
|
|
details.style.display = 'block';
|
|
details.classList.add('task-details-open');
|
|
const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`);
|
|
if (toggleBtn) toggleBtn.classList.add('expanded');
|
|
|
|
const task = this.tasks.find(t => t.id === taskId);
|
|
if (task && (task.status === 'running' || task.status === 'queued' || task.status === 'pending')) {
|
|
this.startLogStreaming(taskId, task);
|
|
} else {
|
|
this.loadTaskLogs(taskId);
|
|
}
|
|
|
|
if (!wasOpen) {
|
|
const row = document.querySelector(`.task-item[data-task-id="${taskId}"]`);
|
|
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
this.highlightedTaskId = taskId;
|
|
return true;
|
|
},
|
|
// Pick which row should open when the page (re)loads, and arm auto-follow.
|
|
//
|
|
// A `?task=` deep link normally wins. The setup-wizard handoff is the
|
|
// exception: it deep-links the FIRST task of its install queue
|
|
// (&from=setup), but by the time the page renders the processor may
|
|
// already be tasks ahead — so for setup arrivals, and for plain visits
|
|
// with no deep link, open whatever is actually running (else next in the
|
|
// queue) and keep following the queue until the user takes over.
|
|
applyInitialSelection() {
|
|
if (!document.getElementById('tasks-list')) return;
|
|
|
|
const params = new URL(window.location.href).searchParams;
|
|
const fromSetup = params.get('from') === 'setup';
|
|
const isActive = (t) => t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending');
|
|
const pinned = this.highlightedTaskId
|
|
? this.tasks.find(t => t.id === this.highlightedTaskId)
|
|
: null;
|
|
|
|
this.followRunning = fromSetup || !pinned;
|
|
|
|
let target = pinned;
|
|
if (this.followRunning) {
|
|
const running = this.tasks.find(t => t.status === 'running');
|
|
// this.tasks is sorted newest-first, so the oldest queued is next to run
|
|
const nextQueued = [...this.tasks].reverse().find(t => t.status === 'queued' || t.status === 'pending');
|
|
target = running || (isActive(pinned) ? pinned : null) || nextQueued || pinned;
|
|
}
|
|
if (!target) return;
|
|
// renderTasks restores the pre-render scroll position one frame from now;
|
|
// selecting after that frame keeps our scrollIntoView from being cancelled.
|
|
const id = target.id;
|
|
requestAnimationFrame(() => requestAnimationFrame(() => this.selectTask(id)));
|
|
},
|
|
// The queue advanced to a new running task — if the user hasn't taken over
|
|
// and we're on the tasks page, move the open panel along with it.
|
|
maybeFollowRunningTask(task) {
|
|
if (!this.followRunning || !task || task.status !== 'running') return;
|
|
if (!document.getElementById('tasks-list')) return;
|
|
if (this.highlightedTaskId === task.id) return;
|
|
const current = this.tasks.find(t => t.id === this.highlightedTaskId);
|
|
if (current && current.status === 'running') return; // never yank a live view
|
|
if (!this.selectTask(task.id)) {
|
|
// Row not rendered yet (task created since the last render) — rebuild
|
|
// the list from the upserted cache, then select.
|
|
this.renderTasks();
|
|
this.selectTask(task.id);
|
|
}
|
|
},
|
|
// Monitor a specific task. State changes now arrive via SSE through
|
|
// TaskEventBus, so this is mostly a hook for the UI to:
|
|
// - auto-expand the task if it's the one we just started
|
|
// - start log streaming when the task transitions to running
|
|
// - clean up local intervals when it terminates
|
|
// No more polling; the only fetches happen on-demand via TaskManager.
|
|
monitorTask(taskId, appName, action) {
|
|
if (!this.highlightedTaskId || this.highlightedTaskId === taskId) {
|
|
setTimeout(() => this.autoExpandTask(taskId), 1500);
|
|
}
|
|
|
|
let statusUpdateInterval = null;
|
|
|
|
const onUpdate = (event) => {
|
|
const t = event.detail && event.detail.task;
|
|
if (!t || t.id !== taskId) return;
|
|
|
|
if (t.status === 'running') {
|
|
this.updateTaskStructure(taskId, t);
|
|
this.startLogStreaming(taskId, t);
|
|
if (this.highlightedTaskId === taskId && !statusUpdateInterval) {
|
|
statusUpdateInterval = setInterval(() => this.updateHighlightedTaskStatus(taskId), 2000);
|
|
}
|
|
} else {
|
|
this.updateTaskDisplay(t);
|
|
}
|
|
};
|
|
|
|
const onComplete = (event) => {
|
|
if (!event.detail || event.detail.taskId !== taskId) return;
|
|
if (statusUpdateInterval) { clearInterval(statusUpdateInterval); statusUpdateInterval = null; }
|
|
this.stopLogStreaming(taskId);
|
|
window.removeEventListener('taskUpdated', onUpdate);
|
|
window.removeEventListener('taskCompleted', onComplete);
|
|
if (this.taskMonitors) this.taskMonitors.delete(taskId);
|
|
};
|
|
|
|
window.addEventListener('taskUpdated', onUpdate);
|
|
window.addEventListener('taskCompleted', onComplete);
|
|
// Track this monitor so the tasks module's unmount() can release it if the
|
|
// user navigates away while the task is still running (these window
|
|
// listeners + interval otherwise outlive the page until the task completes).
|
|
(this.taskMonitors = this.taskMonitors || new Map()).set(taskId, () => {
|
|
if (statusUpdateInterval) { clearInterval(statusUpdateInterval); statusUpdateInterval = null; }
|
|
window.removeEventListener('taskUpdated', onUpdate);
|
|
window.removeEventListener('taskCompleted', onComplete);
|
|
});
|
|
},
|
|
// Auto-expand a task when it's created
|
|
async autoExpandTask(taskId) {
|
|
|
|
// Wait for task to be rendered
|
|
let attempts = 0;
|
|
const maxAttempts = 10;
|
|
|
|
const tryExpand = async () => {
|
|
attempts++;
|
|
|
|
// Check if task element exists
|
|
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
|
|
if (!taskElement) {
|
|
if (attempts < maxAttempts) {
|
|
setTimeout(tryExpand, 500); // Try again in 500ms
|
|
} else {
|
|
console.warn(`⚠️ Could not find task element for ${taskId} after ${maxAttempts} attempts`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
|
|
// Get the details element
|
|
const details = document.getElementById(`details-${taskId}`);
|
|
if (!details) {
|
|
if (attempts < maxAttempts) {
|
|
setTimeout(tryExpand, 500);
|
|
} else {
|
|
console.warn(`⚠️ Could not find details element for ${taskId}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
|
|
// Expand the task details
|
|
details.style.display = 'block';
|
|
details.classList.add('task-details-open');
|
|
|
|
// Update toggle button
|
|
const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`);
|
|
if (toggleBtn) {
|
|
toggleBtn.classList.add('expanded');
|
|
}
|
|
|
|
// Scroll to the task
|
|
taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
// Load the output if task is completed
|
|
const task = await this.taskManager.getTask(taskId);
|
|
if (task && (task.status === 'completed' || task.status === 'failed')) {
|
|
setTimeout(() => {
|
|
this.loadTaskOutput(taskId);
|
|
}, 1000);
|
|
}
|
|
|
|
};
|
|
|
|
tryExpand();
|
|
},
|
|
});
|