// HTMX-based application initialization (function () { "use strict"; // ========================================================================= // CRITICAL: Register auth header listener IMMEDIATELY on document // This MUST run before any HTMX requests are made // ========================================================================= console.log( "[HTMX-AUTH] Registering htmx:configRequest listener on document", ); document.addEventListener("htmx:configRequest", (event) => { // Add Authorization header with access token const accessToken = localStorage.getItem("gb-access-token") || sessionStorage.getItem("gb-access-token"); console.log( "[HTMX-AUTH] configRequest for:", event.detail.path, "token:", accessToken ? accessToken.substring(0, 20) + "..." : "NONE", ); if (accessToken) { event.detail.headers["Authorization"] = `Bearer ${accessToken}`; console.log("[HTMX-AUTH] Authorization header SET"); } else { console.warn( "[HTMX-AUTH] NO TOKEN FOUND - request will be unauthenticated", ); } // Add CSRF token if available const csrfToken = localStorage.getItem("csrf_token"); if (csrfToken) { event.detail.headers["X-CSRF-Token"] = csrfToken; } // Add session ID if available const sessionId = localStorage.getItem("gb-session-id") || sessionStorage.getItem("gb-session-id"); if (sessionId) { event.detail.headers["X-Session-ID"] = sessionId; } }); // Configuration const config = { wsUrl: "/ws", apiBase: "/api", reconnectDelay: 3000, maxReconnectAttempts: 5, }; // State let reconnectAttempts = 0; let wsConnection = null; // Initialize HTMX extensions function initHTMX() { // Configure HTMX htmx.config.defaultSwapStyle = "innerHTML"; htmx.config.defaultSettleDelay = 100; htmx.config.timeout = 10000; // Handle errors globally document.body.addEventListener("htmx:responseError", (event) => { console.error("HTMX Error:", event.detail); showNotification("Connection error. Please try again.", "error"); }); // Handle before swap to prevent errors when target doesn't exist document.body.addEventListener("htmx:beforeSwap", (event) => { const target = event.detail.target; const status = event.detail.xhr?.status; const response = event.detail.serverResponse; const swapStyle = event.detail.swapStyle || "innerHTML"; // If target doesn't exist or response is 404, prevent the swap if (!target || status === 404) { event.detail.shouldSwap = false; console.warn("HTMX swap prevented: target not found or 404 response"); return; } // Check if target is actually in the DOM (prevents insertBefore errors) if (!document.body.contains(target)) { event.detail.shouldSwap = false; console.warn("HTMX swap prevented: target not in DOM"); return; } // Check if target has a parent (required for most swap operations) if (!target.parentNode) { event.detail.shouldSwap = false; console.warn("HTMX swap prevented: target has no parent"); return; } // Additional check: verify parentNode is still in DOM (race condition protection) if ( !document.body.contains(target.parentNode) && target.parentNode !== document.body && target.parentNode !== document.documentElement ) { event.detail.shouldSwap = false; console.warn("HTMX swap prevented: target parent not in DOM"); return; } // For swap styles that use insertBefore, verify the parent can accept children const insertBasedSwaps = [ "outerHTML", "beforebegin", "afterbegin", "beforeend", "afterend", ]; if (insertBasedSwaps.includes(swapStyle)) { try { // Verify we can actually perform DOM operations on the target if ( swapStyle === "outerHTML" && (!target.parentNode || !target.parentNode.contains(target)) ) { event.detail.shouldSwap = false; console.warn( "HTMX swap prevented: outerHTML target detached from parent", ); return; } } catch (e) { event.detail.shouldSwap = false; console.warn("HTMX swap prevented: DOM access error", e); return; } } // For empty responses, set empty content to prevent insertBefore errors if (!response || response.trim() === "") { event.detail.serverResponse = ""; return; } // Validate that response is valid HTML before swapping // This prevents "Unexpected end of input" errors try { const trimmedResponse = response.trim(); // Skip validation for comments if ( trimmedResponse.startsWith("") ) { return; } // Try to parse the response as HTML const parser = new DOMParser(); const doc = parser.parseFromString(response, "text/html"); // Check for parsing errors const parseError = doc.querySelector("parsererror"); if (parseError) { console.warn( "HTMX swap: Response contains invalid HTML, wrapping in div", ); event.detail.serverResponse = "
" + response + "
"; } // Check if body is empty (happens with malformed HTML) if ( doc.body && doc.body.children.length === 0 && doc.body.textContent.trim() === "" ) { if (trimmedResponse.length > 0) { console.warn( "HTMX swap: Response produced empty DOM, preserving as text", ); event.detail.serverResponse = "
" + response + "
"; } } } catch (e) { console.warn("HTMX swap: Error validating response HTML:", e); // Wrap potentially malformed content event.detail.serverResponse = "
" + response + "
"; } }); // Handle swap errors gracefully document.body.addEventListener("htmx:swapError", (event) => { console.error("HTMX swap error:", event.detail); // Don't show notification for swap errors - they're usually timing issues // Prevent the error from propagating event.preventDefault(); }); // Catch any uncaught HTMX errors related to DOM manipulation document.body.addEventListener("htmx:afterRequest", (event) => { // Clean up any orphaned requests const target = event.detail.target; if (target && !document.body.contains(target)) { console.warn( "HTMX afterRequest: target no longer in DOM, cleanup performed", ); } }); // Handle HTMX errors more gracefully document.body.addEventListener("htmx:onLoadError", (event) => { console.error("HTMX load error:", event.detail); }); // Handle successful swaps document.body.addEventListener("htmx:afterSwap", (event) => { // Auto-scroll messages if in chat const messages = document.getElementById("messages"); if (messages && event.detail.target === messages) { messages.scrollTop = messages.scrollHeight; } }); // Handle WebSocket messages document.body.addEventListener("htmx:wsMessage", (event) => { handleWebSocketMessage(JSON.parse(event.detail.message)); }); // Handle WebSocket connection events document.body.addEventListener("htmx:wsConnecting", () => { updateConnectionStatus("connecting"); }); document.body.addEventListener("htmx:wsOpen", () => { updateConnectionStatus("connected"); reconnectAttempts = 0; }); document.body.addEventListener("htmx:wsClose", () => { updateConnectionStatus("disconnected"); attemptReconnect(); }); } // Handle WebSocket messages function handleWebSocketMessage(message) { switch (message.type) { case "message": appendMessage(message); break; case "notification": showNotification(message.text, message.severity); break; case "status": updateStatus(message); break; case "suggestion": addSuggestion(message.text); break; default: console.log("Unknown message type:", message.type); } } // Append message to chat function appendMessage(message) { const messagesEl = document.getElementById("messages"); if (!messagesEl) return; const messageEl = document.createElement("div"); messageEl.className = `message ${message.sender === "user" ? "user" : "bot"}`; messageEl.innerHTML = `
${message.sender} ${escapeHtml(message.text)} ${formatTime(message.timestamp)}
`; messagesEl.appendChild(messageEl); messagesEl.scrollTop = messagesEl.scrollHeight; } // Add suggestion chip function addSuggestion(text) { const suggestionsEl = document.getElementById("suggestions"); if (!suggestionsEl) return; const chip = document.createElement("button"); chip.className = "suggestion-chip"; chip.textContent = text; chip.setAttribute("hx-post", "/api/sessions/current/message"); chip.setAttribute("hx-vals", JSON.stringify({ content: text })); chip.setAttribute("hx-target", "#messages"); chip.setAttribute("hx-swap", "beforeend"); suggestionsEl.appendChild(chip); htmx.process(chip); } // Update connection status function updateConnectionStatus(status) { const statusEl = document.getElementById("connectionStatus"); if (!statusEl) return; statusEl.className = `connection-status ${status}`; statusEl.textContent = status.charAt(0).toUpperCase() + status.slice(1); } // Update general status function updateStatus(message) { const statusEl = document.getElementById("status-" + message.id); if (statusEl) { statusEl.textContent = message.text; statusEl.className = `status ${message.severity}`; } } // Show notification function showNotification(text, type = "info") { const notification = document.createElement("div"); notification.className = `notification ${type}`; notification.textContent = text; const container = document.getElementById("notifications") || document.body; container.appendChild(notification); setTimeout(() => { notification.classList.add("fade-out"); setTimeout(() => notification.remove(), 300); }, 3000); } // Attempt to reconnect WebSocket function attemptReconnect() { if (reconnectAttempts >= config.maxReconnectAttempts) { showNotification("Connection lost. Please refresh the page.", "error"); return; } reconnectAttempts++; setTimeout(() => { console.log(`Reconnection attempt ${reconnectAttempts}...`); htmx.trigger(document.body, "htmx:wsReconnect"); }, config.reconnectDelay); } // Utility: Escape HTML function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } // Utility: Format timestamp function formatTime(timestamp) { if (!timestamp) return ""; const date = new Date(timestamp); return date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, }); } // Handle navigation function initNavigation() { // Update active nav item on page change document.addEventListener("htmx:pushedIntoHistory", (event) => { const path = event.detail.path; updateActiveNav(path); }); // Also listen for htmx:afterSwap to catch all navigation document.addEventListener("htmx:afterSwap", (event) => { setTimeout(() => { const path = window.location.hash || window.location.pathname; updateActiveNav(path); }, 10); }); // Handle hash change window.addEventListener("hashchange", (event) => { updateActiveNav(window.location.hash); }); // Handle browser back/forward window.addEventListener("popstate", (event) => { updateActiveNav(window.location.hash || window.location.pathname); }); // Handle direct clicks on app tabs and app items document.addEventListener("click", (event) => { const appTab = event.target.closest(".app-tab"); const appItem = event.target.closest(".app-item"); if (appTab || appItem) { const element = appTab || appItem; const href = element.getAttribute("href"); if (href) { // Immediately update active state on click updateActiveNav(href); } } }); } // Get current section from URL function getCurrentSection() { const hash = window.location.hash; if (hash && hash.length > 1) { // Handle both #section and /#section formats return hash.replace(/^#\/?/, "").split("/")[0].split("?")[0]; } return "chat"; } // Update active navigation item and page title function updateActiveNav(path) { // Extract section name from path // Handles: "/#chat", "#chat", "/chat", "chat", "/#paper", "#paper" let section; if (path && path.length > 0) { // Remove leading /, #, or /# combinations section = path .replace(/^[/#]+/, "") .split("/")[0] .split("?")[0]; } // Fallback to current URL hash if section is empty if (!section) { section = getCurrentSection(); } // First, remove ALL active classes from all tabs, items, and apps button document.querySelectorAll(".app-tab.active").forEach((item) => { item.classList.remove("active"); }); document.querySelectorAll(".app-item.active").forEach((item) => { item.classList.remove("active"); }); // Remove active from apps button const appsButton = document.getElementById("appsButton"); if (appsButton) { appsButton.classList.remove("active"); } // Check if section exists in the main header tabs let foundInHeaderTabs = false; document.querySelectorAll(".app-tab").forEach((item) => { const dataSection = item.getAttribute("data-section"); const href = item.getAttribute("href"); const itemSection = dataSection || (href ? href.replace(/^#/, "") : ""); if (itemSection === section) { item.classList.add("active"); foundInHeaderTabs = true; } }); // Update app items in launcher dropdown (always mark the current section) document.querySelectorAll(".app-item").forEach((item) => { const href = item.getAttribute("href"); const dataSection = item.getAttribute("data-section"); const itemSection = dataSection || (href ? href.replace(/^#/, "") : ""); if (itemSection === section) { item.classList.add("active"); } }); // If section is NOT in header tabs, select the apps button instead if (!foundInHeaderTabs && appsButton) { appsButton.classList.add("active"); } // Update page title const sectionName = section.charAt(0).toUpperCase() + section.slice(1); document.title = sectionName + " - General Bots"; } // Initialize keyboard shortcuts function initKeyboardShortcuts() { document.addEventListener("keydown", (e) => { // Send message on Enter (when in input) if (e.key === "Enter" && !e.shiftKey) { const input = document.getElementById("messageInput"); if (input && document.activeElement === input) { e.preventDefault(); const form = input.closest("form"); if (form) { htmx.trigger(form, "submit"); } } } // Focus input on / if (e.key === "/" && document.activeElement.tagName !== "INPUT") { e.preventDefault(); const input = document.getElementById("messageInput"); if (input) input.focus(); } // Escape to blur input if (e.key === "Escape") { const input = document.getElementById("messageInput"); if (input && document.activeElement === input) { input.blur(); } } }); } // Initialize scroll behavior function initScrollBehavior() { const scrollBtn = document.getElementById("scrollToBottom"); const messages = document.getElementById("messages"); if (scrollBtn && messages) { // Show/hide scroll button messages.addEventListener("scroll", () => { const isAtBottom = messages.scrollHeight - messages.scrollTop <= messages.clientHeight + 100; scrollBtn.style.display = isAtBottom ? "none" : "flex"; }); // Scroll to bottom on click scrollBtn.addEventListener("click", () => { messages.scrollTo({ top: messages.scrollHeight, behavior: "smooth", }); }); } } // Initialize theme if ThemeManager exists function initTheme() { if (window.ThemeManager) { ThemeManager.init(); } } // Main initialization function init() { console.log("Initializing HTMX application..."); // Initialize HTMX initHTMX(); // Initialize navigation initNavigation(); // Initialize keyboard shortcuts initKeyboardShortcuts(); // Initialize scroll behavior initScrollBehavior(); // Initialize theme initTheme(); // Set initial active nav based on hash or default to chat updateActiveNav(window.location.hash || "#chat"); console.log("HTMX application initialized"); } // Wait for DOM and HTMX to be ready if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } // Expose public API window.BotServerApp = { showNotification, appendMessage, updateConnectionStatus, config, }; })();