From 9dace1ed959d40c68a13073cd6d328ab7ee9b23d Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 11 Jun 2026 18:27:06 +0100 Subject: [PATCH] feat(tasks): auto-select the running task on the tasks page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Fable 5 --- .../components/tasks/js/tasks-list-render.js | 6 +- .../components/tasks/js/tasks-manager.js | 21 ++++- .../components/tasks/js/tasks-row-expand.js | 91 +++++++++++++++++++ 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js index e995833..a0438cc 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js @@ -475,9 +475,11 @@ Object.assign(TasksManager.prototype, { }, filterTasksByCategoryHandler(category) { this.currentCategory = category; - - // Clear specific task filter when switching categories + + // Clear specific task filter when switching categories. Picking a + // category is the user steering the view — auto-follow stands down too. this.highlightedTaskId = null; + this.followRunning = false; // Update URL this.updateURL(category); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-manager.js b/containers/libreportal/frontend/components/tasks/js/tasks-manager.js index 757bb38..f2886a2 100755 --- a/containers/libreportal/frontend/components/tasks/js/tasks-manager.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-manager.js @@ -8,6 +8,10 @@ class TasksManager { this.tasks = []; this.currentCategory = 'all'; this.highlightedTaskId = null; + // When true, the open row tracks whichever task is currently running + // (queue handoffs, setup installs). Cleared the moment the user manually + // toggles a row or switches category — their selection then wins. + this.followRunning = false; // Multi-select: ids of tasks the user has ticked. When non-empty the // "Clear All" button morphs into "Delete Selected (N)". Both paths // share _showClearAllModal — same UX, different filter. @@ -178,13 +182,22 @@ class TasksManager { async init() { //// // console.log('🔧 Initializing TasksManager...'); - + + // Re-read the URL on every (re)mount. The SPA reuses this singleton, so a + // navigation to /tasks/?task=X must refresh the category + deep-link + // state — a constructor-only read goes stale after the first visit. + this.initializeFromURL(); + // Load initial tasks and refresh sidebar counts await this.loadTasks(); - + // Force a refresh to ensure latest data await this.loadTasks(); - + + // Open the row this visit is about (deep link, or the live task) now that + // the first render is in the DOM, and arm running-task auto-follow. + if (typeof this.applyInitialSelection === 'function') this.applyInitialSelection(); + // Setup auto-refresh this.setupAutoRefresh(); @@ -367,6 +380,7 @@ class TasksManager { // any open dropdown's DOM. A targeted update is enough; the next // category switch / refresh will pull the new row in. this.updateTaskDisplay(task); + this.maybeFollowRunningTask(task); }); window.addEventListener('taskUpdated', (e) => { @@ -376,6 +390,7 @@ class TasksManager { if (task.status === 'running') { this.updateTaskStructure(task.id, task); this.startLogStreaming(task.id, task); + this.maybeFollowRunningTask(task); } this.updateTaskDisplay(task); }); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-row-expand.js b/containers/libreportal/frontend/components/tasks/js/tasks-row-expand.js index 48e0bf2..e4e4d39 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-row-expand.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-row-expand.js @@ -1,6 +1,9 @@ // 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}')"]`); @@ -58,6 +61,94 @@ Object.assign(TasksManager.prototype, { 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