const ProgressPanel = { manifest: null, wsConnection: null, // Deprecated - now uses singleton from tasks.js startTime: null, runtimeInterval: null, _boundHandler: null, // Store bound handler for cleanup init(taskId) { // Clean up any existing handler before registering a new one // This prevents duplicate handlers if init is called multiple times if ( this._boundHandler && typeof unregisterTaskProgressHandler === "function" ) { unregisterTaskProgressHandler(this._boundHandler); this._boundHandler = null; } this.taskId = taskId; this.startTime = Date.now(); this.startRuntimeCounter(); this.connectWebSocket(taskId); }, connectWebSocket(taskId) { // Instead of creating our own WebSocket, register with the singleton from tasks.js // This prevents the "2 receivers" problem where manifest_update goes to one connection // while the browser UI is listening on another console.log("[ProgressPanel] Using singleton WebSocket for task:", taskId); // Create bound handler that filters for our task this._boundHandler = (data) => { // Only process messages for our task if (data.task_id && String(data.task_id) !== String(taskId)) { return; } this.handleProgressUpdate(data); }; // Register with the global singleton WebSocket if (typeof registerTaskProgressHandler === "function") { registerTaskProgressHandler(this._boundHandler); console.log("[ProgressPanel] Registered with singleton WebSocket"); } else { // Fallback: wait for tasks.js to load and retry console.log( "[ProgressPanel] Waiting for tasks.js singleton to be available...", ); setTimeout(() => this.connectWebSocket(taskId), 500); } }, handleProgressUpdate(data) { // Skip manifest_update - already handled by tasks.js renderManifestProgress() // Processing it here would cause duplicate updates and race conditions if ( data.type === "manifest_update" || data.event_type === "manifest_update" ) { // Don't process here - tasks.js handles this via handleWebSocketMessage() // which calls renderManifestProgress() with proper normalized ID handling return; } if (data.type === "section_update") { this.updateSection(data.section_id, data.status, data.progress); } else if (data.type === "item_update") { this.updateItem( data.section_id, data.item_id, data.status, data.duration, ); } else if (data.type === "terminal_line") { this.addTerminalLine(data.content, data.line_type); } else if (data.type === "stats_update") { this.updateStats(data.stats); } else if (data.type === "task_progress") { this.handleTaskProgress(data); } }, handleTaskProgress(data) { // Check for manifest in activity if (data.activity && data.activity.manifest) { this.manifest = data.activity.manifest; this.render(); } // Also check for manifest in details (manifest_update events) if ( data.details && (data.step === "manifest_update" || data.event_type === "manifest_update") ) { try { const parsed = typeof data.details === "string" ? JSON.parse(data.details) : data.details; if (parsed && parsed.sections) { this.manifest = parsed; this.render(); } } catch (e) { // Not a manifest JSON, might be terminal output console.debug("Details is not manifest JSON:", e.message); } } if (data.step && data.step !== "manifest_update") { this.updateCurrentAction(data.message || data.step); } // Only add non-manifest details as terminal lines if ( data.details && data.step !== "manifest_update" && data.event_type !== "manifest_update" ) { this.addTerminalLine(data.details, "info"); } }, startRuntimeCounter() { this.runtimeInterval = setInterval(() => { const elapsed = Math.floor((Date.now() - this.startTime) / 1000); const runtimeEl = document.getElementById("status-runtime"); if (runtimeEl) { runtimeEl.textContent = this.formatDuration(elapsed); } }, 1000); }, stopRuntimeCounter() { if (this.runtimeInterval) { clearInterval(this.runtimeInterval); this.runtimeInterval = null; } }, formatDuration(seconds) { if (seconds < 60) { return `${seconds} sec`; } else if (seconds < 3600) { const mins = Math.floor(seconds / 60); return `${mins} min`; } else { const hours = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); return `${hours} hr ${mins} min`; } }, render() { if (!this.manifest) return; this.renderStatus(); this.renderProgressLog(); this.renderTerminal(); }, renderStatus() { const titleEl = document.getElementById("status-title"); if (titleEl) { titleEl.textContent = this.manifest.description || this.manifest.app_name; } const estimatedEl = document.getElementById("estimated-time"); if (estimatedEl && this.manifest.estimated_seconds) { estimatedEl.textContent = this.formatDuration( this.manifest.estimated_seconds, ); } const currentAction = this.getCurrentAction(); const actionEl = document.getElementById("current-action"); if (actionEl && currentAction) { actionEl.textContent = currentAction; } this.updateDecisionPoint(); }, getCurrentAction() { if (!this.manifest || !this.manifest.sections) return null; for (const section of this.manifest.sections) { if (section.status === "Running") { for (const child of section.children || []) { if (child.status === "Running") { for (const item of child.items || []) { if (item.status === "Running") { return item.name; } } return child.name; } } return section.name; } } return null; }, updateCurrentAction(action) { const actionEl = document.getElementById("current-action"); if (actionEl) { actionEl.textContent = action; } }, updateDecisionPoint() { const decisionStepEl = document.getElementById("decision-step"); const decisionTotalEl = document.getElementById("decision-total"); if (decisionStepEl && this.manifest) { decisionStepEl.textContent = this.manifest.completed_steps || 0; } if (decisionTotalEl && this.manifest) { decisionTotalEl.textContent = this.manifest.total_steps || 0; } }, renderProgressLog() { const container = document.getElementById("progress-log-content"); if (!container || !this.manifest || !this.manifest.sections) return; container.innerHTML = ""; for (const section of this.manifest.sections) { const sectionEl = this.createSectionElement(section); container.appendChild(sectionEl); } }, createSectionElement(section) { const sectionDiv = document.createElement("div"); sectionDiv.className = "log-section"; sectionDiv.dataset.sectionId = section.id; if (section.status === "Running" || section.status === "Completed") { sectionDiv.classList.add("expanded"); } const statusClass = section.status.toLowerCase(); // Support both direct fields and nested progress object const stepCurrent = section.current_step ?? section.progress?.current ?? 0; const stepTotal = section.total_steps ?? section.progress?.total ?? 0; sectionDiv.innerHTML = `
${this.escapeHtml(section.name)} View Details ▸ Step ${stepCurrent}/${stepTotal} ${section.status}
`; const childrenContainer = sectionDiv.querySelector(".log-children"); for (const child of section.children || []) { const childEl = this.createChildElement(child, section.id); childrenContainer.appendChild(childEl); } if ( section.items && section.items.length > 0 && (!section.children || section.children.length === 0) ) { for (const item of section.items) { const itemEl = this.createItemElement(item); childrenContainer.appendChild(itemEl); } } return sectionDiv; }, createChildElement(child, parentId) { const childDiv = document.createElement("div"); childDiv.className = "log-child"; childDiv.dataset.childId = child.id; if (child.status === "Running" || child.status === "Completed") { childDiv.classList.add("expanded"); } const statusClass = child.status.toLowerCase(); // Support both direct fields and nested progress object const stepCurrent = child.current_step ?? child.progress?.current ?? 0; const stepTotal = child.total_steps ?? child.progress?.total ?? 0; const duration = child.duration_seconds ? this.formatDuration(child.duration_seconds) : ""; childDiv.innerHTML = `
${this.escapeHtml(child.name)} View Details ▸ Step ${stepCurrent}/${stepTotal} ${child.status}
`; const itemsContainer = childDiv.querySelector(".log-items"); for (const item of child.items || []) { const itemEl = this.createItemElement(item); itemsContainer.appendChild(itemEl); } return childDiv; }, createItemElement(item) { const itemDiv = document.createElement("div"); itemDiv.className = "log-item"; itemDiv.dataset.itemId = item.id; const statusClass = item.status.toLowerCase(); const duration = item.duration_seconds ? `Duration: ${this.formatDuration(item.duration_seconds)}` : ""; const checkIcon = item.status === "Completed" ? "✓" : item.status === "Running" ? "◎" : "○"; itemDiv.innerHTML = ` ${this.escapeHtml(item.name)}${item.details ? ` - ${this.escapeHtml(item.details)}` : ""}
${duration} ${checkIcon}
`; return itemDiv; }, renderTerminal() { // Support both formats: terminal_output (direct) and terminal.lines (web JSON) const terminalLines = this.manifest?.terminal_output || this.manifest?.terminal?.lines || []; if (!terminalLines.length) return; const container = document.getElementById("terminal-content"); if (!container) return; container.innerHTML = ""; for (const line of terminalLines.slice(-50)) { this.appendTerminalLine( container, line.content, line.type || line.line_type || "info", ); } container.scrollTop = container.scrollHeight; }, addTerminalLine(content, lineType) { const container = document.getElementById("terminal-content"); if (!container) return; this.appendTerminalLine(container, content, lineType); container.scrollTop = container.scrollHeight; this.incrementProcessedCount(); }, appendTerminalLine(container, content, lineType) { const lineDiv = document.createElement("div"); lineDiv.className = `terminal-line ${lineType || "info"}`; lineDiv.textContent = content; container.appendChild(lineDiv); }, incrementProcessedCount() { const processedEl = document.getElementById("terminal-processed"); if (processedEl) { const current = parseInt(processedEl.textContent, 10) || 0; processedEl.textContent = current + 1; } }, updateStats(stats) { const processedEl = document.getElementById("terminal-processed"); if (processedEl && stats.data_points_processed !== undefined) { processedEl.textContent = stats.data_points_processed; } const speedEl = document.getElementById("terminal-speed"); if (speedEl && stats.sources_per_min !== undefined) { speedEl.textContent = `~${stats.sources_per_min.toFixed(1)} sources/min`; } const etaEl = document.getElementById("terminal-eta"); if (etaEl && stats.estimated_remaining_seconds !== undefined) { etaEl.textContent = this.formatDuration( stats.estimated_remaining_seconds, ); } }, updateSection(sectionId, status, progress) { const sectionEl = document.getElementById("window-tasks").querySelector( `[data-section-id="${sectionId}"]`, ); if (!sectionEl) return; const indicator = sectionEl.querySelector(".section-indicator"); const statusBadge = sectionEl.querySelector(".section-status-badge"); const stepBadge = sectionEl.querySelector(".section-step-badge"); if (indicator) { indicator.className = `section-indicator ${status.toLowerCase()}`; } if (statusBadge) { statusBadge.className = `section-status-badge ${status.toLowerCase()}`; statusBadge.textContent = status; } if (stepBadge && progress) { stepBadge.textContent = `Step ${progress.current}/${progress.total}`; } if (status === "Running" || status === "Completed") { sectionEl.classList.add("expanded"); } }, updateItem(sectionId, itemId, status, duration) { const itemEl = document.getElementById("window-tasks").querySelector(`[data-item-id="${itemId}"]`); if (!itemEl) return; const dot = itemEl.querySelector(".item-dot"); const check = itemEl.querySelector(".item-check"); const durationEl = itemEl.querySelector(".item-duration"); const statusClass = status.toLowerCase(); if (dot) { dot.className = `item-dot ${statusClass}`; } if (check) { check.className = `item-check ${statusClass}`; check.textContent = status === "Completed" ? "✓" : status === "Running" ? "◎" : "○"; } if (durationEl && duration) { durationEl.textContent = `Duration: ${this.formatDuration(duration)}`; } }, toggleSection(sectionId) { const sectionEl = document.getElementById("window-tasks").querySelector( `[data-section-id="${sectionId}"]`, ); if (sectionEl) { sectionEl.classList.toggle("expanded"); } }, toggleChild(childId) { const childEl = document.getElementById("window-tasks").querySelector(`[data-child-id="${childId}"]`); if (childEl) { childEl.classList.toggle("expanded"); } }, viewDetails(sectionId) { console.log("View details for section:", sectionId); }, viewChildDetails(childId) { console.log("View details for child:", childId); }, escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; }, loadManifest(taskId) { fetch(`/api/autotask/${taskId}/manifest`) .then((response) => response.json()) .then((data) => { if (data.success && data.manifest) { this.manifest = data.manifest; this.render(); } }) .catch((error) => { console.error("Failed to load manifest:", error); }); }, destroy() { this.stopRuntimeCounter(); // Unregister from singleton instead of closing our own connection if ( this._boundHandler && typeof unregisterTaskProgressHandler === "function" ) { unregisterTaskProgressHandler(this._boundHandler); this._boundHandler = null; console.log("[ProgressPanel] Unregistered from singleton WebSocket"); } // Don't close the singleton connection - other components may be using it this.wsConnection = null; }, }; function toggleLogSection(header) { const section = header.closest(".log-section"); if (section) { section.classList.toggle("expanded"); } } function toggleLogChild(header) { const child = header.closest(".log-child"); if (child) { child.classList.toggle("expanded"); } } function viewSectionDetails(sectionId) { ProgressPanel.viewDetails(sectionId); } function viewChildDetails(childId) { ProgressPanel.viewChildDetails(childId); } window.ProgressPanel = ProgressPanel;