// Enhanced App Manager with Tabbed Interface // Integrates app management with task history class AppTabbedManager { constructor() { // console.log('🔍 AppTabbedManager constructor called'); // console.log('🔍 URL in constructor:', window.location.href); // console.log('🔍 Search params in constructor:', window.location.search); // Store original URL for task parameter detection this.originalUrl = window.location.href; this.originalSearch = window.location.search; // Check sessionStorage for task parameter (fallback) const sessionTaskId = sessionStorage.getItem('pendingTaskId'); // console.log('🔍 Session storage task ID:', sessionTaskId); // Debug: Check if task parameter exists in original URL const originalParams = new URLSearchParams(this.originalSearch); const originalTaskId = originalParams.get('task'); // console.log('🔍 Original task ID in constructor:', originalTaskId); // Try to get task parameter from performance navigation if available if (performance && performance.getEntriesByType) { const navigationEntries = performance.getEntriesByType('navigation'); if (navigationEntries.length > 0) { const navEntry = navigationEntries[0]; // console.log('🔍 Navigation entry URL:', navEntry.name); const navParams = new URLSearchParams(new URL(navEntry.name).search); const navTaskId = navParams.get('task'); // console.log('🔍 Navigation task ID:', navTaskId); } } this.currentApp = this.getAppFromURL(); this.currentTab = this.getTabFromURL(); this.tasksManager = new TasksManager(); this.appsManager = new AppsManager(); this.initialized = false; // Button state management this.disabledButtons = new Set(); this.activeTaskId = null; // Track running tasks. Key is `${appName}|${action}` (using `|` so app names with // hyphens don't collide). Value is { taskId, appName, action } so callers never // have to parse the key. this.runningTasks = new Map(); } // Build the runningTasks key for an (app, action) pair. Use `|` since `-` and `_` // appear in app/action strings (e.g. 'delete_all'). taskKey(appName, action) { return `${appName}|${action}`; } // Find the most recent running task for the given app, or null. getRunningTaskForApp(appName) { if (!appName) return null; for (const info of this.runningTasks.values()) { if (info.appName === appName) return info; } return null; } // Switch the manager to a new app, clearing DOM-bound state from the previous app // and re-evaluating tab/button state for the new one. Callers (e.g. apps-manager) // should use this instead of mutating `currentApp` directly so disabled tabs from // app A don't bleed into app B's view. setCurrentApp(appName) { if (this.currentApp === appName) return; // console.log('🔄 setCurrentApp: switching from %s to %s', this.currentApp, appName); this.currentApp = appName; // Before clearing disabled button references, restore any static backup action buttons // that may have spinners/disabled state from a previous app const backupActions = document.querySelectorAll('.backup-actions button'); backupActions.forEach(button => { if (button && button.dataset.originalContent) { button.disabled = false; button.classList.remove('disabled', 'task-running'); button.innerHTML = button.dataset.originalContent; delete button.dataset.originalContent; } }); this.disabledButtons.clear(); this.activeTaskId = null; this.enableTabs(); const running = this.getRunningTaskForApp(appName); // console.log('🔍 setCurrentApp: running task for %s = %o', appName, running); if (running) { this.activeTaskId = running.taskId; } } // Get app name from URL parameter getAppFromURL() { const urlParams = new URLSearchParams(window.location.search); let appName = urlParams.get('app'); // Fallback to old format if app param not found if (!appName) { const fullPath = window.location.search; if (fullPath.includes('?=')) { const [basePath, query] = fullPath.split('?='); appName = query.split('&')[0]; // Get only the app name, ignore other params } } // console.log('🔍 Original app name from URL:', appName); // Convert full app name to slug for task filtering if (appName && window.apps) { const appData = window.apps.find(app => { // Extract slug from command const command = app.command || ''; const parts = command.split(' '); return parts[parts.length - 1] === appName; }); if (appData) { const command = appData.command || ''; const parts = command.split(' '); const slug = parts[parts.length - 1]; // Return the slug // console.log('🔄 Converted to slug:', slug, 'from appData:', appData.name); return slug; } else { // console.log('⚠️ No app data found for:', appName); } } // console.log('🔄 Returning original app name:', appName); return appName; } // Get tab from URL parameter getTabFromURL() { const currentUrl = window.location.href; const urlParams = new URLSearchParams(window.location.search); const tab = urlParams.get('tab') || 'config'; // console.log('🔍 getTabFromURL debug:', { //currentUrl: currentUrl, //search: window.location.search, //tabParam: urlParams.get('tab'), //defaultTab: 'config', //finalTab: tab === 'logs' ? 'tasks' : tab //}); // Convert "logs" to "tasks" for backward compatibility return tab === 'logs' ? 'tasks' : tab; } // Check if we're on an app page before doing anything isAppPage() { const pathname = window.location.pathname; const search = window.location.search; // Only individual app pages (/app?=appname), NOT the apps listing page (/apps) return (pathname.startsWith('/app') && !pathname.startsWith('/apps') || pathname.endsWith('/index.html') || pathname === '/index.html' || search.includes('app=') || search.includes('?=')); // Old format app pages } // Update URL with app and tab updateURL(app = null, tab = null) { // console.log('🔍 updateURL called with:', { app, tab }); // console.log('🔍 Current URL before update:', window.location.href); // Only update URLs on app pages - prevent interference with other pages if (!this.isAppPage()) { // console.log('🚫 Not on app page, skipping URL update'); return; } const url = new URL(window.location); const params = new URLSearchParams(url.search); const fullPath = window.location.search; // Define here for both blocks // Handle both old format (?=appname) and new format (?app=appname) if (app) { // Check if we're using the old format if (fullPath.includes('?=')) { // Update old format: /app?=appname&tab=tabname const newURL = `/app?=${app}`; if (tab) { // console.log('🔄 Updating URL to:', `${newURL}&tab=${tab}`); window.history.replaceState({}, '', `${newURL}&tab=${tab}`); } else { // console.log('🔄 Updating URL to:', newURL); window.history.replaceState({}, '', newURL); } } else { // Update new format: /app?app=appname&tab=tabname if (tab) { params.set('app', app); params.set('tab', tab); } else { params.set('app', app); } const newSearch = params.toString(); // console.log('🔄 Updating URL to:', `${window.location.pathname}?${newSearch}`); window.history.replaceState({}, '', `${window.location.pathname}?${newSearch}`); } } else { // Only updating tab, preserve existing app and task parameters if (fullPath.includes('?=')) { // Old format: preserve app and task, update tab const currentApp = params.get('=') || this.currentApp; const currentTask = params.get('task'); let newURL = `/app?=${currentApp}&tab=${tab}`; if (currentTask) { newURL += `&task=${currentTask}`; } // console.log('🔄 Updating URL (old format) to:', newURL); window.history.replaceState({}, '', newURL); } else { // New format: preserve app and task, update tab const currentApp = params.get('app') || this.currentApp; const currentTask = params.get('task'); params.set('app', currentApp); params.set('tab', tab); if (currentTask) { params.set('task', currentTask); } const newSearch = params.toString(); // console.log('🔄 Updating URL (new format) to:', `${window.location.pathname}?${newSearch}`); window.history.replaceState({}, '', `${window.location.pathname}?${newSearch}`); } } } // Update current app and refresh content updateApp(newAppName) { this.setCurrentApp(newAppName); // Reset URL to config tab const currentUrl = window.location.href; let newUrl; if (currentUrl.includes('tab=')) { newUrl = currentUrl.replace(/tab=[^&]*/, 'tab=config'); } else { newUrl = `${currentUrl}&tab=config`; } history.replaceState({}, '', newUrl); this.switchTab('config'); } // Switch between tabs switchTab(tabId) { // console.log('🔄 switchTab called with:', tabId); // console.log('🔍 Current currentApp before switch:', this.currentApp); // console.log('🔍 Current URL when switching:', window.location.href); // console.log('🔍 URL search when switching:', window.location.search); // Remove active class from all main navigation tabs document.querySelectorAll('.main-tab-button').forEach(btn => { btn.classList.remove('active'); }); // Hide all tab panes document.querySelectorAll('.tab-pane').forEach(pane => { pane.classList.remove('active'); }); // Add active class to selected main navigation tab const selectedTab = document.querySelector(`.main-tab-button[data-tab="${tabId}"]`); if (selectedTab) { selectedTab.classList.add('active'); //// // console.log('✅ Tab button activated:', tabId); } else { console.warn('⚠️ Main navigation tab button not found:', tabId); } // Add active class to selected tab pane const selectedPane = document.getElementById(`${tabId}-tab`); if (selectedPane) { selectedPane.classList.add('active'); //// // console.log('✅ Tab pane activated:', tabId); } else { console.warn('⚠️ Tab pane not found:', tabId); } // Update URL (only tab, not app) - but only on app pages if (this.isAppPage()) { // console.log('🔄 About to updateURL with tab:', tabId); this.updateURL(null, tabId); } // Load tab-specific content // console.log('🔄 About to load tab content for tab:', tabId, 'with currentApp:', this.currentApp); this.loadTabContent(tabId); } // Load content for specific tab async loadTabContent(tabId) { const actualTabId = tabId === 'logs' ? 'tasks' : tabId; const currentAppFromUrl = this.getAppFromURL(); // console.log('📂 loadTabContent: tabId=%s, currentApp=%s, fromUrl=%s', // tabId, this.currentApp, currentAppFromUrl); // Update currentApp if URL has different app name. Route through setCurrentApp // so any disable state from the previous app gets cleared before we render. if (currentAppFromUrl && currentAppFromUrl !== this.currentApp) { this.setCurrentApp(currentAppFromUrl); } // Ensure app detail view is shown and app is loaded before loading tab content if (!this.currentApp || this.currentApp === 'null') { console.warn('⚠️ No current app set, cannot load tab content'); return; } // Toggle the Tools tab button visibility based on whether this app has // any tools. Tools-less apps simply don't see the tab. If the user // landed on the tools tab via a deep link for such an app, redirect // them to config so they're not staring at an empty pane. if (window.toolsManager) { const toolsResult = await window.toolsManager.prepare(this.currentApp); if (actualTabId === 'tools' && (!toolsResult || toolsResult.tools.length === 0)) { return this.switchTab('config'); } } // Routing tab is Traefik-only — show on Traefik, hide everywhere else. const isTraefik = this.currentApp === 'traefik'; document.querySelectorAll('[data-tab="routing"]').forEach(btn => { btn.style.display = isTraefik ? '' : 'none'; }); if (actualTabId === 'routing' && !isTraefik) { return this.switchTab('config'); } // Make sure app detail view is visible and app is loaded if (window.appsManager) { // Use showAppDetail to ensure proper initialization (same as config tab) // console.log('🔄 Ensuring app detail is loaded for:', this.currentApp); window.appsManager.showAppDetail(this.currentApp); // Wait a bit for DOM to be ready after app detail is rendered await new Promise(resolve => setTimeout(resolve, 200)); } switch (actualTabId) { case 'tasks': // console.log('🔄 loadTabContent: Loading tasks for app:', this.currentApp); await this.loadAppTasks(); break; case 'backups': // console.log('🔄 loadTabContent: Loading backups for app:', this.currentApp); await this.loadAppBackups(); // IMPORTANT: Re-apply button state if there are running tasks this.restoreButtonState(); break; case 'services': if (window.servicesManager) { await window.servicesManager.load(this.currentApp); } this.restoreButtonState(); break; case 'tools': if (window.toolsManager) { await window.toolsManager.load(this.currentApp); } this.restoreButtonState(); break; case 'routing': if (window.routingManager) { await window.routingManager.load(this.currentApp); } this.restoreButtonState(); break; case 'config': // Config is already handled by showAppDetail above // console.log('🔧 Config content already loaded by showAppDetail'); // IMPORTANT: Re-apply button state if there are running tasks this.restoreButtonState(); break; default: // Config is handled by existing app management system break; } // Tear down the services tab (timers + SSE) when switching away. if (actualTabId !== 'services' && window.servicesManager) { window.servicesManager.unload(); } if (actualTabId !== 'tools' && window.toolsManager) { window.toolsManager.unload(); } } // Load tasks specific to current app async loadAppTasks() { // console.log('🔄 loadAppTasks called, currentApp:', this.currentApp); // Show loading spinner by showing the initial loading state const tasksContainer = document.getElementById('app-tasks'); if (tasksContainer) { tasksContainer.innerHTML = `
Loading tasks...
No app selected.
'; } return; } try { // Load all tasks // console.log('🔄 Loading tasks...'); // console.log('🔍 Using currentApp for filtering:', this.currentApp); await this.tasksManager.loadTasks(); const allTasks = this.tasksManager.tasks || []; // console.log('📊 All tasks loaded:', allTasks.length); // console.log('📋 All tasks data:', allTasks); // console.log('📋 Sample task app names:', allTasks.slice(0, 3).map(t => t.app)); // Filter tasks for current app const appTasks = allTasks.filter(task => task.app === this.currentApp); // console.log('🎯 Filtering tasks for app:', this.currentApp); // console.log('📋 Available task.app values:', [...new Set(allTasks.map(t => t.app))]); // console.log('🎯 Filtered tasks for', this.currentApp, ':', appTasks.length); // Debug: Show what would match if we used different app names // console.log('🔍 Debug - Testing different app names:'); ['libreportal', 'fail2ban', 'LibrePortal', 'Fail2Ban'].forEach(testApp => { const testTasks = allTasks.filter(task => task.app === testApp); // console.log(` - "${testApp}": ${testTasks.length} tasks`); }); if (appTasks.length === 0) { // console.log('⚠️ No tasks found for', this.currentApp, '- checking if tasks have different app names'); // Show some task details for debugging if (allTasks.length > 0) { // console.log('📋 Sample tasks:', allTasks.slice(0, 3).map(t => ({ id: t.id, app: t.app, command: t.command }))); } tasksContainer.innerHTML = `No tasks found for ${this.currentApp}.
`; return; } // Setup global functions for task interactions this.tasksManager.setupGlobalFunctions(); // Render app-specific tasks const tasksHtml = appTasks.map(task => this.tasksManager.renderTask(task)).join(''); tasksContainer.innerHTML = tasksHtml; // Setup app-specific task interactions (separate from main tasks system) this.setupAppTaskFunctions(); // Handle pending task ID from URL parameter if (this.pendingTaskId) { // console.log('🔍 Handling pending task ID after tasks loaded:', this.pendingTaskId); setTimeout(() => { if (typeof window.toggleAppTaskDetails === 'function') { // console.log('🔍 Opening task details for pending task:', this.pendingTaskId); window.toggleAppTaskDetails(this.pendingTaskId); // Scroll to the task element after opening details this.scrollToTask(this.pendingTaskId); this.pendingTaskId = null; // Clear pending task ID } }, 500); // Wait a bit for DOM to be ready } // Task events are handled by individual task components // No additional initialization needed } catch (error) { console.error('AppTabbedManager: Error loading app tasks:', error); tasksContainer.innerHTML = `Error loading tasks: ${error.message}
`; } } // Scroll to specific task element with smooth animation scrollToTask(taskId) { // console.log('🔍 Scrolling to task:', taskId); // Find the task element by ID or data attribute let taskElement = document.getElementById(`task-${taskId}`); // If not found by ID, try to find by data-task-id attribute if (!taskElement) { taskElement = document.querySelector(`[data-task-id="${taskId}"]`); } // If still not found, try to find the task details element if (!taskElement) { const detailsElement = document.getElementById(`details-${taskId}`); if (detailsElement) { taskElement = detailsElement.closest('.task-item'); } } if (taskElement) { // console.log('🔍 Found task element, scrolling to it:', taskElement); // Smooth scroll to the task element taskElement.scrollIntoView({ behavior: 'smooth', block: 'center', // Center the task in the viewport inline: 'nearest' }); // Add a highlight effect to make the task more visible taskElement.classList.add('task-highlighted'); // Remove the highlight after 3 seconds setTimeout(() => { taskElement.classList.remove('task-highlighted'); }, 3000); } else { console.warn('⚠️ Task element not found for scrolling:', taskId); } } // Setup app-specific task functions to avoid conflicts with main tasks page setupAppTaskFunctions() { // Create app-specific toggleTaskDetails function window.toggleAppTaskDetails = (taskId) => { // console.log('🔍 App-specific toggleTaskDetails called for:', taskId); 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 document.querySelectorAll('.task-details').forEach(d => { if (d.id !== `details-${taskId}`) { d.style.display = 'none'; d.classList.remove('task-details-open'); } }); document.querySelectorAll('.task-btn.toggle-details').forEach(btn => { btn.classList.remove('expanded'); }); if (isOpen) { details.style.display = 'none'; details.classList.remove('task-details-open'); if (toggleBtn) toggleBtn.classList.remove('expanded'); } else { details.style.display = 'block'; details.classList.add('task-details-open'); if (toggleBtn) toggleBtn.classList.add('expanded'); // Auto-load logs when opened if (this.tasksManager && this.tasksManager.loadTaskLogs) { this.tasksManager.loadTaskLogs(taskId); } // Update URL to include task parameter const currentUrl = window.location.href; const urlParams = new URLSearchParams(currentUrl.search); // Get current app from AppTabbedManager const currentApp = this.currentApp || ''; // Construct proper URL with correct parameter order const newUrl = `/app?=${currentApp}&tab=tasks&task=${taskId}`; // console.log('🔍 Updating URL with task parameter:', newUrl); history.pushState({}, '', newUrl); } } else { console.warn('⚠️ App task details not found for:', taskId); } }; // Override global toggleTaskDetails to use app-specific version when on app page const originalToggleTaskDetails = window.toggleTaskDetails; window.toggleTaskDetails = (taskId) => { if (window.appTabbedManager && window.location.pathname.includes('/app')) { // Use app-specific version window.toggleAppTaskDetails(taskId); } else { // Use original version for main tasks page originalToggleTaskDetails(taskId); } }; } // Load app backups async loadAppBackups() { const backupAppNameElement = document.getElementById('backup-app-name'); if (backupAppNameElement) { const formattedAppName = this.currentApp ? (window.getAppDisplayName ? window.getAppDisplayName(this.currentApp) : (this.currentApp.charAt(0).toUpperCase() + this.currentApp.slice(1))) : 'Unknown App'; backupAppNameElement.textContent = formattedAppName; } if (!this.currentApp || typeof BackupAppCard === 'undefined') { const status = document.getElementById('backup-app-card-status'); if (status) status.textContent = 'No app selected.'; return; } if (!this.backupAppCard || this.backupAppCard.appName !== this.currentApp) { this.backupAppCard = new BackupAppCard(this.currentApp); window.backupAppCard = this.backupAppCard; } await this.backupAppCard.render(); } // Initialize the tabbed manager async initialize() { // Prevent double initialization if (this.initialized) { // console.log('⚠️ AppTabbedManager already initialized, skipping'); return; } // console.log('🚀 AppTabbedManager initializing, currentApp:', this.currentApp); // Initialize task system if not already done (with retry) if (this.tasksManager && !this.tasksManager.commands) { let initialized = false; let attempts = 0; const maxAttempts = 5; while (!initialized && attempts < maxAttempts) { // console.log(`🔄 Attempting to initialize task system (${attempts + 1}/${maxAttempts})...`); try { initialized = this.tasksManager.initializeTaskSystem(); if (initialized) { // console.log('✅ Task system initialized successfully'); } } catch (error) { console.error('❌ Task system initialization error:', error); } if (!initialized) { attempts++; await new Promise(resolve => setTimeout(resolve, 200)); // Wait 200ms } } if (!initialized) { console.warn('⚠️ Task system initialization failed after retries'); } } // Stale .task-running from a prior session won't survive a reload, but the // DOM might still carry it from a re-render — clear it up front. this.enableTabs(); document.querySelectorAll('button.task-running, .tab-button.task-running, .main-tab-button.task-running') .forEach(button => this.restoreButton(button)); // SSE drives task lifecycle events; the reconcile pass below is a // safety net for the cases where they don't reach us — bus disconnect, // missed event during reconnect, throttled background tab. Several // triggers so a stuck "running" state self-heals quickly: // * a faster periodic tick (5s) instead of the previous 30s // * whenever the SSE bus reconnects (`taskBusReady`) // * whenever the page becomes visible again (`visibilitychange`) // * whenever the window regains focus if (!this._watchdogStarted) { this._watchdogStarted = true; const reconcile = () => this.reconcileRunningTasks().catch(() => {}); // Adaptive cadence: when a task is actively running, poll every // 1.5s so a missed SSE event surfaces quickly. When idle, fall back // to 5s. Net: worst-case lag drops from ~10s to ~1.5s while keeping // background load minimal. const tick = () => { reconcile(); const next = (this.runningTasks && this.runningTasks.size > 0) ? 1500 : 5000; clearTimeout(this._reconcileTimer); this._reconcileTimer = setTimeout(tick, next); }; this._reconcileTimer = setTimeout(tick, 1500); window.addEventListener('taskBusReady', reconcile); window.addEventListener('focus', reconcile); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') reconcile(); }); } // Set current app from URL BEFORE setting up URL monitoring const urlAppName = this.getAppFromURL(); // console.log('🔍 Setting initial currentApp from URL:', urlAppName); this.currentApp = urlAppName; // Check for running tasks for this app and auto-switch to tasks tab if found if (this.currentApp) { const running = this.getRunningTaskForApp(this.currentApp); if (running) { this.switchTab('tasks'); this.disableTabs(); this.activeTaskId = running.taskId; } } // Check for task parameter and handle it AFTER tasks are loaded // Use original URL since the current URL might have been modified const urlParams = new URLSearchParams(this.originalSearch); // console.log('🔍 Original URL search during init:', this.originalSearch); // console.log('🔍 Original URL params during init:', Object.fromEntries(urlParams.entries())); let taskId = urlParams.get('task'); // console.log('🔍 Task ID from original params:', taskId); // Fallback: Check sessionStorage if URL doesn't have task parameter if (!taskId) { taskId = sessionStorage.getItem('pendingTaskId'); // console.log('🔍 Task ID from sessionStorage fallback:', taskId); // Clear sessionStorage after using it if (taskId) { sessionStorage.removeItem('pendingTaskId'); } } if (taskId) { // console.log('🔍 Task parameter found:', taskId); // Store the task ID to handle after tasks are loaded this.pendingTaskId = taskId; // Force tasks tab this.switchTab('tasks'); } // Monitor URL changes for app navigation this.setupURLMonitoring(); // Listen for task creation events this.setupTaskEventListeners(); // Set initial active tab (only if no task parameter) if (!taskId) { const initialTab = this.getTabFromURL(); // console.log('🔄 Setting initial tab:', initialTab, 'with currentApp:', this.currentApp); this.switchTab(initialTab); } // Set global reference for other components window.appTabbedManager = this; } // Monitor URL changes for app navigation setupURLMonitoring() { // Listen for popstate events (browser back/forward) window.addEventListener('popstate', () => { if (!this.isAppPage()) return; // Only monitor on app pages const newAppName = this.getAppFromURL(); // Only update if currentApp is already set and app actually changed if (this.currentApp && newAppName !== this.currentApp) { // console.log('🔄 URL changed, updating app from', this.currentApp, 'to', newAppName); this.updateApp(newAppName); } }); } // Watch for new tasks and switch to logs tab watchForTaskCreation() { // Auto-switch to Tasks tab when a fresh task appears for the current app. setInterval(async () => { if (this.currentApp && this.currentTab !== 'tasks') { // Skip if a recent uninstall asked us not to auto-switch. const until = this._suppressTaskAutoSwitch?.get(this.currentApp); if (until && Date.now() < until) return; if (until) this._suppressTaskAutoSwitch.delete(this.currentApp); try { await this.tasksManager.loadTasks(); const allTasks = this.tasksManager.tasks || []; // Only switch on RUNNING/QUEUED tasks created recently — completed // ones don't need watching, and would otherwise bounce the user // back to Tasks right after they've been switched away. const recentTasks = allTasks.filter(task => task.app === this.currentApp && (task.status === 'running' || task.status === 'queued' || task.status === 'pending') && new Date(task.createdAt) > new Date(Date.now() - 5000) ); if (recentTasks.length > 0) this.switchTab('tasks'); } catch (error) { console.error('Error watching for tasks:', error); } } }, 5000); } // Create backup (placeholder function) async createBackup(appName) { // Placeholder - will be implemented with actual backup logic // console.log(`Creating backup for ${appName}...`); } // Setup task event listeners for button state management setupTaskEventListeners() { window.addEventListener('taskCreated', (event) => { const { taskId, appName, action } = event.detail; const key = this.taskKey(appName, action); // console.log('📌 taskCreated: appName=%s, currentApp=%s, action=%s, key=%s', // appName, this.currentApp, action, key); if (this.runningTasks.has(key)) { const existing = this.runningTasks.get(key); // Same task firing twice — `createAndExecuteTask` dispatches taskCreated // synchronously, and the SSE bus also dispatches it when the new task // file shows up. Both events carry the same taskId; treat as a no-op. if (existing && existing.taskId === taskId) return; if (window.notificationSystem) { // Match the per-task-type icon used everywhere else (install ✅, // backup 💾, etc.) so the user sees *what kind* of task is in // progress, not just a generic warning triangle. const typeIcon = (window.tasksManager && window.tasksManager.getTaskTypeIcon ? window.tasksManager.getTaskTypeIcon({ type: action }) : null)?.icon || ''; const customIcon = typeIcon ? `${typeIcon}` : null; window.notificationSystem.show( `Task Already Running