PageSourceSearch

https://chaos-industries.info/wp-content/plugins/mxchat-basic/js/chat-script.js?ver=3.2.22

js chaos-industries.info collected 2026-10-02 01:57:15 UTC 214,933 bytes, 4,971 lines download raw bytes

1jQuery(document).ready(function($) {
2
3    // Nonce refresh — v2 (plan-6a68c9).
4    //
5    // The widget no longer relies on a nonce embedded in inline cached HTML.
6    // Before each chat-send / stream-send / upload, we call the REST endpoint
7    // GET /wp-json/mxchat/v1/nonce and use the freshly-issued value. The
8    // endpoint creates the nonce with action `mxchat_chat_send`; the server-side
9    // verifier ALSO still accepts the legacy `mxchat_chat_nonce` action for a
10    // 30-day backwards-compat window so cached pages still in users' browsers
11    // (which carry the legacy inline-localized nonce) keep working.
12    //
13    // Cache: a single module-scoped slot. TTL 12h conservatively (WP nonces are
14    // 24h but we refetch at half-life so a freshly-cached-page user never sees
15    // a borderline-stale nonce).
16    var cachedFreshNonce = null;
17    var cachedFreshNonceFetchedAt = 0;
18    var NONCE_TTL_MS = 12 * 60 * 60 * 1000;
19    var nonceRefreshState = 'idle'; // 'idle' | 'pending' | 'done'
20    var nonceRefreshCallbacks = [];
21
22    function getRestNonceUrl() {
23        if (typeof mxchatChat !== 'undefined' && mxchatChat.rest_url) {
24            return mxchatChat.rest_url.replace(/\/+$/, '') + '/nonce';
25        }
26        // Fallback: derive from current origin if mxchatChat.rest_url isn't set.
27        return window.location.origin + '/wp-json/mxchat/v1/nonce';
28    }
29
30    function fetchFreshNonceFromRest() {
31        return fetch(getRestNonceUrl(), {
32            credentials: 'same-origin',
33            headers: { 'Accept': 'application/json' }
34        }).then(function (resp) {
35            if (!resp.ok) {
36                throw new Error('REST nonce fetch failed: ' + resp.status);
37            }
38            return resp.json();
39        }).then(function (data) {
40            if (data && data.nonce) {
41                return data.nonce;
42            }
43            throw new Error('REST nonce response had no nonce field.');
44        });
45    }
46
47    /**
48     * withFreshNonce(cb) — invoke cb() after ensuring mxchatChat.nonce is fresh.
49     * Tries REST endpoint first (cache-bypass design); falls back to the legacy
50     * admin-ajax refresh path if REST is unavailable. Idempotent — concurrent
51     * calls share the same in-flight refresh.
52     */
53    function withFreshNonce(callback) {
54        if (typeof mxchatChat === 'undefined') {
55            if (callback) callback();
56            return;
57        }
58        var now = Date.now();
59        if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60            mxchatChat.nonce = cachedFreshNonce;
61            if (callback) callback();
62            return;
63        }
64        if (callback) nonceRefreshCallbacks.push(callback);
65        if (nonceRefreshState === 'pending') return;
66        nonceRefreshState = 'pending';
67
68        var resolved = function (nonce) {
69            if (nonce) {
70                cachedFreshNonce = nonce;
71                cachedFreshNonceFetchedAt = Date.now();
72                mxchatChat.nonce = nonce;
73            }
74            nonceRefreshState = 'done';
75            var pending = nonceRefreshCallbacks;
76            nonceRefreshCallbacks = [];
77            pending.forEach(function (cb) { try { cb(); } catch (e) {} });
78        };
79
80        fetchFreshNonceFromRest()
81            .then(resolved)
82            .catch(function () {
83                // Fallback to the legacy admin-ajax refresh path (issued with the
84                // old action `mxchat_chat_nonce`; the server still accepts both
85                // during the compat window).
86                if (mxchatChat.ajax_url) {
87                    $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' })
88                        .done(function (res) {
89                            if (res && res.success && res.data && res.data.nonce) {
90                                resolved(res.data.nonce);
91                                return;
92                            }
93                            resolved(null);
94                        })
95                        .fail(function () { resolved(null); });
96                } else {
97                    resolved(null);
98                }
99            });
100    }
101
102    // Backwards-compat alias — every existing caller in this file (and any
103    // out-of-tree consumer that hit this internal API) keeps working unchanged.
104    function refreshNonceIfNeeded(callback) {
105        return withFreshNonce(callback);
106    }
107
108    // Dynamic-settings refresh (plan-32db95).
109    //
110    // Every widget setting ships inline in cached page HTML, so behind a
111    // full-page cache the site owner can't purge (host cache, CDN, the
112    // browser itself) a toggled setting looks broken until the cache turns
113    // over. Same distrust-cached-HTML reasoning as the per-request nonce:
114    // on the FIRST widget open per page load we ask the nonce endpoint for
115    // the current behavior-gate settings (?with_settings=1), merge them over
116    // mxchatChat, and rebuild the header menu. Colors are NOT refreshed —
117    // they're server-inline-styled, so a runtime swap would visibly flash.
118    // On any failure we keep the inline values silently (nonce-fallback
119    // posture). At most one request per page load, only if a widget opens.
120    var dynamicSettingsState = 'idle'; // 'idle' | 'pending' | 'done'
121
122    function mxchatRefreshDynamicSettings() {
123        if (dynamicSettingsState !== 'idle') return;
124        if (typeof mxchatChat === 'undefined') return;
125        dynamicSettingsState = 'pending';
126
127        var applied = function (data) {
128            dynamicSettingsState = 'done';
129            if (!data) return; // endpoint unavailable — inline values stand.
130            if (data.nonce) {
131                // Seed the nonce cache too: saves the first send's REST
132                // round-trip and keeps us under the endpoint's rate limit.
133                cachedFreshNonce = data.nonce;
134                cachedFreshNonceFetchedAt = Date.now();
135                mxchatChat.nonce = data.nonce;
136            }
137            if (data.settings && typeof data.settings === 'object') {
138                $.extend(mxchatChat, data.settings);
139                mxchatRebuildHeaderMenus();
140            }
141        };
142
143        fetch(getRestNonceUrl() + '?with_settings=1', {
144            credentials: 'same-origin',
145            headers: { 'Accept': 'application/json' }
146        }).then(function (resp) {
147            if (!resp.ok) throw new Error('settings refresh failed: ' + resp.status);
148            return resp.json();
149        }).then(applied).catch(function () {
150            // Fallback: legacy admin-ajax refresh path, same as withFreshNonce.
151            if (mxchatChat.ajax_url) {
152                $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce', with_settings: 1 })
153                    .done(function (res) {
154                        applied(res && res.success && res.data ? res.data : null);
155                    })
156                    .fail(function () { applied(null); });
157            } else {
158                applied(null);
159            }
160        });
161    }
162
163    // ====================================
164    // MULTI-INSTANCE MANAGEMENT SYSTEM
165    // ====================================
166
167    // Instance registry - tracks all chatbot instances on the page
168    const MxChatInstances = {
169        instances: {},
170
171        // Lifecycle facts add-ons can read after the fact (plan 65c9b6). The
172        // events below fire once; a script that loads late checks these instead.
173        toolbarReady: false,
174        opened: {},
175        hasOpened: function(botId) {
176            return !!this.opened[botId || 'default'];
177        },
178
179        // Initialize an instance for a bot
180        init: function(botId) {
181            if (!this.instances[botId]) {
182                // When persistence is OFF, track when this session started
183                // so the AI only sees messages from this page load
184                var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
185
186                this.instances[botId] = {
187                    botId: botId,
188                    sessionId: null,
189                    lastSeenMessageId: '',
190                    notificationCheckInterval: null,
191                    pollingInterval: null,
192                    processedMessageIds: new Set(),
193                    activePdfFile: null,
194                    activeWordFile: null,
195                    chatHistoryLoaded: false,
196                    isStreaming: false,
197                    // Fresh context timestamp - only used when persistence is OFF
198                    sessionStartTimestamp: chatPersistenceEnabled ? 0 : Date.now()
199                };
200            }
201            return this.instances[botId];
202        },
203
204        // Get instance by botId
205        get: function(botId) {
206            return this.instances[botId] || this.init(botId);
207        },
208
209        // Get all active bot IDs
210        getAllBotIds: function() {
211            return Object.keys(this.instances);
212        },
213
214        // Session management per bot
215        // Returns existing session ID from cookie or localStorage (with in-memory fallback),
216        // or null if none exists. Does NOT create a new session — use ensureSession() for that.
217        getChatSession: function(botId) {
218            var cookieName = 'mxchat_session_id_' + botId;
219            var storageKey = 'mxchat_session_id_' + botId;
220            var sessionId = getCookie(cookieName);
221
222            // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent)
223            if (!sessionId) {
224                try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
225            }
226
227            // Fallback to in-memory instance when cookie AND localStorage are both blocked
228            // (Safari ITP, strict tracking prevention, cross-origin iframes with partitioned
229            // storage). Without this, ensureSession() can generate and store an ID that
230            // getChatSession() then can't read back, causing null session_ids on send.
231            if (!sessionId && this.instances[botId] && this.instances[botId].sessionId) {
232                sessionId = this.instances[botId].sessionId;
233            }
234
235            // Guard against stored sentinel values that indicate earlier broken writes.
236            if (sessionId === 'null' || sessionId === 'undefined') {
237                sessionId = null;
238            }
239
240            // Re-sync cookie from localStorage if cookie was lost
241            if (sessionId && !getCookie(cookieName)) {
242                document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
243            }
244
245            return sessionId || null;
246        },
247
248        // Lazy session initializer — called on first user interaction
249        ensureSession: function(botId) {
250            botId = botId || 'default';
251            var instance = this.instances[botId] || this.init(botId);
252
253            if (instance.sessionId) {
254                return instance.sessionId;
255            }
256
257            // Check for existing session from cookie or localStorage
258            var existingSession = this.getChatSession(botId);
259
260            if (existingSession) {
261                instance.sessionId = existingSession;
262            } else {
263                // Brand new session
264                var newId = generateSessionId();
265                this.setChatSession(botId, newId);
266                instance.sessionId = newId;
267            }
268
269            // Now that we have a session, do the deferred work
270            refreshNonceIfNeeded();
271            trackOriginatingPage();
272
273            // Note: loadChatHistory is handled by showChatContainerForBot with loader UI,
274            // so we do NOT call it here to avoid a race condition.
275
276            return instance.sessionId;
277        },
278
279        setChatSession: function(botId, sessionId) {
280            var cookieName = 'mxchat_session_id_' + botId;
281            var storageKey = 'mxchat_session_id_' + botId;
282            document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
283            try { localStorage.setItem(storageKey, sessionId); } catch (e) {}
284            if (this.instances[botId]) {
285                this.instances[botId].sessionId = sessionId;
286            }
287        },
288
289        resetChatSession: function(botId) {
290            // Clear old session from localStorage before setting new one
291            try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
292            var newSessionId = generateSessionId();
293            this.setChatSession(botId, newSessionId);
294            var $chatBox = getElement(botId, 'chat-box');
295            if ($chatBox.length) {
296                // Keep the greeting, drop everything else. Identify the greeting
297                // by its marker class, NOT by position (plan a1a79b): after a
298                // chat-persistence restore the first .bot-message is a real
299                // reply, so ":not(:first)" left a stale answer sitting at the
300                // top of an otherwise empty box. The positional fallback only
301                // runs when the marker is absent — a page served from HTML cache
302                // that predates this release — and behaves exactly as before.
303                if ($chatBox.find('.mxchat-intro-message').length) {
304                    $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove();
305                } else {
306                    $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
307                }
308            }
309            if (this.instances[botId]) {
310                this.instances[botId].chatHistoryLoaded = false;
311                this.instances[botId].processedMessageIds = new Set();
312            }
313        },
314
315        // Silent reset — new session ID without clearing the chat UI
316        // Used when IP changes mid-conversation so the user doesn't see messages vanish
317        silentResetSession: function(botId) {
318            try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
319            var newSessionId = generateSessionId();
320            this.setChatSession(botId, newSessionId);
321            if (this.instances[botId]) {
322                this.instances[botId].sessionId = newSessionId;
323            }
324            return newSessionId;
325        }
326    };
327
328    // ====================================
329    // LIFECYCLE EVENTS FOR ADD-ONS (plan 65c9b6)
330    // ====================================
331    //
332    // Document-level CustomEvents an add-on listens for instead of polling the
333    // DOM or firing requests on page load:
334    //   mxchat:toolbar-ready  detail { enabled }          once, right after the
335    //                         toolbar show/hide pass; items rendered through the
336    //                         mxchat_chat_toolbar_items filter can bind now.
337    //   mxchat:opened         detail { botId, embedded }  once per bot per page
338    //                         load, the first time its window is shown — the
339    //                         launcher, the pre-chat bubble, an embedded widget
340    //                         (at load), or any script that reveals it.
341    // Same facts, readable late: MxChatInstances.toolbarReady / hasOpened(botId).
342    function mxchatDispatch(name, detail) {
343        try {
344            document.dispatchEvent(new CustomEvent(name, { detail: detail || {} }));
345        } catch (err) { /* CustomEvent unsupported — nothing to notify */ }
346    }
347
348    function mxchatFireOpened(botId) {
349        botId = botId || 'default';
350        if (MxChatInstances.opened[botId]) return;
351        MxChatInstances.opened[botId] = true;
352        mxchatDispatch('mxchat:opened', { botId: botId, embedded: isEmbeddedBot(botId) });
353    }
354
355    // Catch openers this file does not own (the Trigger add-on, theme buttons,
356    // custom scripts): a floating window gaining .visible counts as opened.
357    function mxchatWatchForOpen(botId) {
358        var el = getElementDOM(botId, 'floating-chatbot');
359        if (!el || typeof MutationObserver === 'undefined') return;
360        if (el.classList.contains('visible')) { mxchatFireOpened(botId); return; }
361        var obs = new MutationObserver(function() {
362            if (el.classList.contains('visible')) {
363                mxchatFireOpened(botId);
364                obs.disconnect();
365            }
366        });
367        obs.observe(el, { attributes: true, attributeFilter: ['class'] });
368    }
369
370    // ====================================
371    // ELEMENT SELECTOR HELPERS
372    // ====================================
373
374    // Check if a specific bot has an AI theme assigned (skip inline colors)
375    function shouldSkipInlineColors(botId) {
376        // If global AI theme is active, skip inline colors for all bots
377        if (mxchatChat.skip_inline_colors) {
378            return true;
379        }
380        // Check if this specific bot has a theme assignment
381        var botAssignments = mxchatChat.bot_theme_assignments || {};
382        return botAssignments.hasOwnProperty(botId);
383    }
384
385    // Get element by ID with bot suffix - returns jQuery object
386    function getElement(botId, elementName) {
387        return $('#' + elementName + '-' + botId);
388    }
389
390    // Get element by ID with bot suffix - returns DOM element
391    function getElementDOM(botId, elementName) {
392        return document.getElementById(elementName + '-' + botId);
393    }
394
395    // Get bot ID from any element within a chatbot instance
396    function getBotIdFromElement(element) {
397        var $wrapper = $(element).closest('.mxchat-chatbot-wrapper');
398        if ($wrapper.length) {
399            return $wrapper.data('bot-id') || 'default';
400        }
401        // Fallback: try to find from floating container
402        var $floating = $(element).closest('.floating-chatbot');
403        if ($floating.length) {
404            var id = $floating.attr('id') || '';
405            var match = id.match(/floating-chatbot-(.+)/);
406            if (match) return match[1];
407        }
408        // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
409        // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
410        // .close-pre-chat-message button, which carries only a class and no id — miss both
411        // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
412        // and read the suffix. (closest() includes the element itself, so a click directly on
413        // #pre-chat-message-{bot_id} resolves here too.)
414        var $preChat = $(element).closest('[id^="pre-chat-message-"]');
415        if ($preChat.length) {
416            var preId = $preChat.attr('id') || '';
417            var preMatch = preId.match(/^pre-chat-message-(.+)$/);
418            if (preMatch) return preMatch[1];
419        }
420        // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
421        var elementId = $(element).attr('id') || '';
422        if (elementId) {
423            // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
424            var idMatch = elementId.match(/^(?:floating-chatbot-button|pre-chat-message|chat-notification-badge)-(.+
424)$/);
425            if (idMatch) return idMatch[1];
426        }
427        return 'default';
428    }
429
430    // Get wrapper element for a bot
431    function getWrapper(botId) {
432        return getElement(botId, 'mxchat-chatbot-wrapper');
433    }
434
435    // ====================================
436    // GLOBAL VARIABLES & CONFIGURATION
437    // ====================================
438    const toolbarIconColor = mxchatChat.toolbar_icon_color || '#212121';
439
440    // Initialize color settings (these are global as they come from PHP)
441    var userMessageBgColor = mxchatChat.user_message_bg_color;
442    var userMessageFontColor = mxchatChat.user_message_font_color;
443    var botMessageBgColor = mxchatChat.bot_message_bg_color;
444    var botMessageFontColor = mxchatChat.bot_message_font_color;
445    var liveAgentMessageBgColor = mxchatChat.live_agent_message_bg_color;
446    var liveAgentMessageFontColor = mxchatChat.live_agent_message_font_color;
447
448    var linkTarget = mxchatChat.link_target_toggle === 'on' ? '_blank' : '_self';
449
450    // ====================================
451    // SESSION MANAGEMENT (Legacy compatibility)
452    // ====================================
453
454    function getCookie(name) {
455        let value = "; " + document.cookie;
456        let parts = value.split("; " + name + "=");
457        if (parts.length == 2) return parts.pop().split(";").shift();
458    }
459
460    function generateSessionId() {
461        // Session IDs function as the de-facto bearer token for an anonymous
462        // chat, so generate them with a CSPRNG when available. Math.random is a
463        // legacy fallback for ancient/sandboxed environments that lack
464        // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other
465        // code pattern-matches on it). (plan-0c17b5)
466        var rand;
467        try {
468            if (window.crypto && window.crypto.getRandomValues) {
469                var buf = new Uint8Array(16); // 128 bits
470                window.crypto.getRandomValues(buf);
471                rand = Array.prototype.map.call(buf, function (b) {
472                    return ('0' + b.toString(16)).slice(-2);
473                }).join('');
474            }
475        } catch (e) {}
476        if (!rand) {
477            rand = Math.random().toString(36).substr(2, 9); // legacy fallback
478        }
479        return 'mxchat_chat_' + rand;
480    }
481
482    // Legacy function - now delegates to instance manager
483    function getChatSession(botId) {
484        botId = botId || 'default';
485        return MxChatInstances.getChatSession(botId);
486    }
487
488    function setChatSession(sessionId, botId) {
489        botId = botId || 'default';
490        MxChatInstances.setChatSession(botId, sessionId);
491    }
492
493    function resetChatSession(botId) {
494        botId = botId || 'default';
495        MxChatInstances.resetChatSession(botId);
496    }
497
498    // ====================================
499    // INITIALIZE ALL CHATBOT INSTANCES
500    // ====================================
501
502    function initializeAllInstances() {
503        // Find all chatbot wrappers on the page
504        $('.mxchat-chatbot-wrapper').each(function() {
505            var botId = $(this).data('bot-id') || 'default';
506            MxChatInstances.init(botId);
507            initializeBotInstance(botId);
508        });
509    }
510
511    function initializeBotInstance(botId) {
512        var instance = MxChatInstances.get(botId);
513
514        // Initialize quick questions state for this bot
515        checkQuickQuestionsState(botId);
516
517        // Note: Event handlers use event delegation with class selectors,
518        // so they work automatically for all instances without per-bot setup
519    }
520
521// ====================================
522// CONTEXTUAL AWARENESS FUNCTIONALITY
523// ====================================
524
525// Plan f3e515: the page context is computed once per page, not once per message.
526// Keyed on URL + title + the content element's text length — cheap to check,
527// and enough to notice a navigation or a re-rendered content area.
528var pageContextCache = { key: null, value: null };
529
530function getPageContext() {
531    // Check if contextual awareness is enabled
532    if (mxchatChat.contextual_awareness_toggle !== 'on') {
533        return null;
534    }
535
536    // Get page URL
537    const pageUrl = window.location.href;
538
539    // Get page title
540    const pageTitle = document.title || '';
541
542    // Get main content from the page
543    let pageContent = '';
544
545    // Try to get content from common content areas
546    const contentSelectors = [
547        'main',
548        '[role="main"]',
549        '.content',
550        '.main-content',
551        '.post-content',
552        '.entry-content',
553        '.page-content',
554        'article',
555        '#content',
556        '#main'
557    ];
558
559    let contentElement = null;
560    for (const selector of contentSelectors) {
561        contentElement = document.querySelector(selector);
562        if (contentElement) {
563            break;
564        }
565    }
566
567    // If no specific content area found, use body but exclude header, footer, nav, sidebar
568    if (!contentElement) {
569        contentElement = document.body;
570    }
571
572    if (contentElement) {
573        const cacheKey = pageUrl + '\u0000' + pageTitle + '\u0000' + (contentElement.textContent || '').length;
574        if (pageContextCache.key === cacheKey) {
575            return pageContextCache.value;
576        }
577
578        // Copy the element into a document that has no browsing context
579        // (plan f3e515). A live cloneNode(true) constructed real <video> /
580        // <audio> / <img> elements: media with a src runs resource selection
581        // on creation whether or not it is attached, and an autoplay attribute
582        // made the detached copy PLAY — audibly, once the visible player had
583        // been unmuted through the property. Every message spawned another
584        // one. Elements in an inert document never fetch or play, while
585        // querySelectorAll / textContent / dataset all still work on them.
586        const inertDocument = document.implementation.createHTMLDocument('');
587        const clone = inertDocument.importNode(contentElement, true);
588
589        // Remove unwanted elements
590        const unwantedSelectors = [
591            'header',
592            'footer',
593            'nav',
594            '.navigation',
595            '.sidebar',
596            '.widget',
597            '.menu',
598            'script',
599            'style',
600            '.comments',
601            '#comments',
602            '.breadcrumb',
603            '.breadcrumbs',
604            '#floating-chatbot',
605            '#floating-chatbot-button',
606            '.mxchat',
607            '[class*="chat"]',
608            '[id*="chat"]',
609            // Media contributes no text and (outside the inert document) fetches.
610            'video',
611            'audio',
612            'iframe',
613            'object',
614            'embed',
615            'picture',
616            'img',
617            'source'
618        ];
619
620        unwantedSelectors.forEach(selector => {
621            const elements = clone.querySelectorAll(selector);
622            elements.forEach(el => el.remove());
623        });
624
625        //   Extract MxChat context data attributes before getting text content
626        const contextData = [];
627        clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
628            const contextValue = el.dataset.mxchatContext;
629            if (contextValue && contextValue.trim()) {
630                contextData.push(contextValue);
631            }
632        });
633
634        // Get text content and clean it up
635        pageContent = clone.textContent || clone.innerText || '';
636
637        // Add context data to page content if any were found
638        if (contextData.length > 0) {
639            pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
640        }
641
642        // Clean up whitespace and limit length
643        pageContent = pageContent
644            .replace(/\s+/g, ' ')
645            .trim()
646            .substring(0, 3000); // Limit to 3000 characters to avoid token limits
647
648        // Only return context if we have meaningful content
649        const result = (!pageContent || pageContent.length < 50) ? null : {
650            url: pageUrl,
651            title: pageTitle,
652            content: pageContent
653        };
654        pageContextCache = { key: cacheKey, value: result };
655        return result;
656    }
657
658    return null;
659}
660
661//   Track originating page when chat starts
662function trackOriginatingPage() {
663    const sessionId = getChatSession();
664    const pageUrl = window.location.href;
665    const pageTitle = document.title || 'Untitled Page';
666    
667    // Only track once per session
668    const trackingKey = 'mxchat_originating_tracked_' + sessionId;
669    if (sessionStorage.getItem(trackingKey)) {
670        return;
671    }
672    
673    $.ajax({
674        url: mxchatChat.ajax_url,
675        type: 'POST',
676        data: {
677            action: 'mxchat_track_originating_page',
678            session_id: sessionId,
679            page_url: pageUrl,
680            page_title: pageTitle,
681            nonce: mxchatChat.nonce
682        },
683        success: function(response) {
684            if (response.success) {
685                sessionStorage.setItem(trackingKey, 'true');
686            }
687        }
688    });
689}
690
691// ====================================
692// CORE CHAT FUNCTIONALITY
693// ====================================
694
695// Helper functions to disable/enable chat input while waiting for response
696function disableChatInput(botId) {
697    botId = botId || 'default';
698    var chatInput = getElementDOM(botId, 'chat-input');
699    var sendButton = getElementDOM(botId, 'send-button');
700    if (chatInput) {
701        chatInput.disabled = true;
702        chatInput.style.opacity = '0.6';
703    }
704    if (sendButton) {
705        sendButton.disabled = true;
706        sendButton.style.opacity = '0.5';
707        sendButton.style.pointerEvents = 'none';
708    }
709}
710
711// Whether the input may grab focus after a completed reply (plan 03799f).
712// On coarse-pointer devices focusing a text input summons the on-screen
713// keyboard over the answer the visitor is trying to read, so 'auto' (the
714// default) focuses only on fine-pointer devices. The site-wide
715// mxchat_autofocus_after_reply PHP filter can force 'on'/'off'.
716// NOT used on widget open (:~3424) — that focus is a deliberate act and is
717// what makes the widget keyboard-accessible.
718function mxchatShouldAutofocusAfterReply() {
719    var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto';
720    if (pref === 'on') return true;
721    if (pref === 'off') return false;
722    try {
723        return !window.matchMedia('(pointer: coarse)').matches;
724    } catch (err) {
725        return true;
726    }
727}
728
729function enableChatInput(botId) {
730    botId = botId || 'default';
731    var chatInput = getElementDOM(botId, 'chat-input');
732    var sendButton = getElementDOM(botId, 'send-button');
733    if (chatInput) {
734        chatInput.disabled = false;
735        chatInput.style.opacity = '1';
736        if (mxchatShouldAutofocusAfterReply()) {
737            try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
738        }
739    }
740    if (sendButton) {
741        sendButton.disabled = false;
742        sendButton.style.opacity = '1';
743        sendButton.style.pointerEvents = 'auto';
744    }
745    // Every completion path re-enables input, so this is the single restore
746    // point for the streaming Stop affordance (no-op when not in stop mode).
747    mxchatRestoreSendButton(botId);
748}
749
750// --- Streaming Stop control -------------------------------------------------
751// One live stream handle per bot instance, so Stop on one widget never aborts
752// another bot on the same page.
753var mxchatActiveStreams = {};
754// Original send-button markup, captured once per bot the first time the Stop
755// state is shown (never captured while already in stop mode, so a rapid
756// stop-then-resend can't save the stop glyph as the "original").
757var mxchatSendMarkup = {};
758
759function mxchatShowStopButton(botId) {
760    var btn = getElementDOM(botId, 'send-button');
761    if (!btn) return;
762    if (!btn.classList.contains('mxchat-stop-mode')) {
763        mxchatSendMarkup[botId] = {
764            html: btn.innerHTML,
765            label: btn.getAttribute('aria-label')
766        };
767    }
768
769    // Mirror the send icon's rendered size + color so the stop glyph looks
770    // native, including custom send images/colors and theme overrides.
771    var child = btn.querySelector('svg, img');
772    var size = 25;
773    var color = '';
774    if (child) {
775        var rect = child.getBoundingClientRect();
776        if (rect.width) {
777            size = Math.round(Math.min(rect.width, rect.height));
778        }
779        var cs = window.getComputedStyle(child);
780        color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
781    }
782    var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
783    btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" style="width:' + size + 'px;height:' + size + 'px;' + (color ? 'fill:' + color + ';' : '') + '"><rect x="5" y="5" width="14" height="14" rx="3"></rect></svg>';
784    // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
785    // start a stream synchronously while the originating click is still
786    // bubbling up to our delegated handler. Without this guard, that handler
787    // reads the just-added stop-mode class as a user Stop press and aborts the
788    // brand-new stream — the user's message renders but no reply ever fires
789    // (plan-4bba64 silent message loss). The flag only spans the current event
790    // dispatch: cleared on the next macrotask, long before a real Stop click.
791    btn.__mxchatStopJustShown = true;
792    setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
793    btn.classList.add('mxchat-stop-mode');
794    btn.setAttribute('aria-label', stopLabel);
795    btn.setAttribute('title', stopLabel);
796    // disableChatInput() ran when the turn was sent; the Stop control itself
797    // must stay clickable while the textarea remains disabled.
798    btn.disabled = false;
799    btn.style.opacity = '1';
800    btn.style.pointerEvents = 'auto';
801}
802
803function mxchatRestoreSendButton(botId) {
804    var btn = getElementDOM(botId, 'send-button');
805    var saved = mxchatSendMarkup[botId];
806    if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
807    btn.innerHTML = saved.html;
808    btn.classList.remove('mxchat-stop-mode');
809    btn.removeAttribute('title');
810    if (saved.label) {
811        btn.setAttribute('aria-label', saved.label);
812    }
813}
814
815function mxchatStopStreaming(botId) {
816    var entry = mxchatActiveStreams[botId];
817    if (!entry || !entry.controller) return;
818    entry.aborted = true;
819    try { entry.controller.abort(); } catch (e) {}
820}
821
822// Returns true when a stream rejection came from an intentional Stop click:
823// keep the partial text as the turn's answer — no error UI, no fallback resend.
824function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
825    var entry = mxchatActiveStreams[botId];
826    if (!entry || !entry.aborted) return false;
827    delete mxchatActiveStreams[botId];
828    if (!accumulatedContent) {
829        // Stopped before the first chunk: drop the thinking bubble, no orphan message.
830        getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
831    }
832    enableChatInput(botId); // also restores the send icon
833    if (callback) {
834        callback(accumulatedContent || '');
835    }
836    return true;
837}
838
839// Update your existing sendMessage function
840function sendMessage(botId) {
841    botId = botId || 'default';
842    MxChatInstances.ensureSession(botId);
843    var $chatInput = getElement(botId, 'chat-input');
844    var message = $chatInput.val();
845
846    // ADD PROMPT HOOK HERE
847    if (typeof customMxChatFilter === 'function') {
848        message = customMxChatFilter(message, "prompt");
849    }
850
851    if (message) {
852        // Don't disable input in live agent mode - let users chat freely
853        var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
854        var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
855        if (!isAgentMode) {
856            disableChatInput(botId);
857        }
858
859        appendMessage("user", message, '', [], false, botId);
860        $chatInput.val('');
861        mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
862        $chatInput.css('height', 'auto');
863
864        if (hasQuickQuestions(botId)) {
865            collapseQuickQuestions(botId);
866        }
867        appendThinkingMessage(botId);
868        scrollToBottom(botId);
869
870        const currentModel = mxchatChat.model || 'gpt-5.6-sol';
871
872        // Check if streaming is enabled AND supported for this model
873        if (shouldUseStreaming(currentModel)) {
874            callMxChatStream(message, function(response) {
875                // Content is final: releasing aria-busy lets the live region
876                // announce the completed reply once (plan 67f126).
877                getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
878            }, botId);
879        } else {
880            callMxChat(message, function(response) {
881                replaceLastMessage("bot", response, '', [], botId);
882            }, botId);
883        }
884    }
885}
886
887// Update your existing sendMessageToChatbot function
888function sendMessageToChatbot(message, botId) {
889    botId = botId || 'default';
890    MxChatInstances.ensureSession(botId);
891
892    // ADD PROMPT HOOK HERE
893    if (typeof customMxChatFilter === 'function') {
894        message = customMxChatFilter(message, "prompt");
895    }
896
897    // Don't disable input in live agent mode - let users chat freely
898    var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
899    var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
900    if (!isAgentMode) {
901        disableChatInput(botId);
902    }
903
904    var sessionId = getChatSession(botId);
905
906    if (hasQuickQuestions(botId)) {
907        collapseQuickQuestions(botId);
908    }
909    appendThinkingMessage(botId);
910    scrollToBottom(botId);
911
912    const currentModel = mxchatChat.model || 'gpt-5.6-sol';
913
914    // Check if streaming is enabled AND supported for this model
915    if (shouldUseStreaming(currentModel)) {
916        callMxChatStream(message, function(response) {
917            // Final content — release aria-busy so the reply announces once (67f126).
918            getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
919        }, botId);
920    } else {
921        callMxChat(message, function(response) {
922            getElement(botId, 'chat-box').find('.temporary-message').remove();
923            replaceLastMessage("bot", response, '', [], botId);
924        }, botId);
925    }
926}
927
928// Updated shouldUseStreaming function with debugging
929function shouldUseStreaming(model) {
930    // Check if streaming is enabled in settings (using your toggle naming pattern)
931    const streamingEnabled = mxchatChat.enable_streaming_toggle === 'on';
932    
933    // Check if model supports streaming
934    const streamingSupported = isStreamingSupported(model);
935    
936    
937    // Only use streaming if both enabled and supported
938    return streamingEnabled && streamingSupported;
939}
940
941// Helper function to handle chat mode updates
942function handleChatModeUpdates(response, responseText) {
943    // Check for explicit chat mode in response (THIS IS THE KEY FIX)
944    if (response.chat_mode) {
945        updateChatModeIndicator(response.chat_mode);
946        return; // Return early since we found explicit mode
947    }
948    // Check for fallback response chat mode
949    else if (response.fallbackResponse && response.fallbackResponse.chat_mode) {
950        updateChatModeIndicator(response.fallbackResponse.chat_mode);
951        return; // Return early since we found explicit mode
952    }
953    
954    // Only do text-based detection if no explicit mode was provided
955    // Check for specific AI chatbot response text
956    if (responseText === 'You are now chatting with the AI chatbot.' || 
957        responseText.includes('now chatting with the AI') ||
958        responseText.includes('switched to AI mode') ||
959        responseText.includes('AI chatbot is now')) {
960        updateChatModeIndicator('ai');
961    }
962    // Check for agent transfer messages
963    else if (responseText.includes('agent') && 
964             (responseText.includes('transfer') || responseText.includes('connected'))) {
965        updateChatModeIndicator('agent');
966    }
967}
968
969// Function to get bot ID from any element or wrapper
970// If element is provided, finds the bot ID from its wrapper
971// If no element, returns 'default' (for backward compatibility)
972function getMxChatBotId(element) {
973    if (element) {
974        return getBotIdFromElement(element);
975    }
976    // Fallback: find first chatbot wrapper on page
977    const chatbotWrapper = document.querySelector('.mxchat-chatbot-wrapper');
978    return chatbotWrapper ? chatbotWrapper.getAttribute('data-bot-id') || 'default' : 'default';
979}
980
981function callMxChat(message, callback, botId) {
982    botId = botId || getMxChatBotId();
983
984    // Streaming fallbacks land here: drop any leftover stream handle and
985    // return the button to its send state (no-op for plain non-stream turns).
986    if (mxchatActiveStreams[botId]) {
987        delete mxchatActiveStreams[botId];
988    }
989    mxchatRestoreSendButton(botId);
990
991    // Store the message in case we need to retry after session reset
992    getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
993
994    // Get page context if contextual awareness is enabled
995    const pageContext = getPageContext();
996
997    // Get instance for session start timestamp (used when persistence is OFF)
998    var instance = MxChatInstances.get(botId);
999
1000    // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
1001    // and returns the guaranteed-present session id from the in-memory instance even when
1002    // cookie/localStorage writes are silently blocked by the browser.
1003    var sessionId = MxChatInstances.ensureSession(botId);
1004    if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
1005        // Last-resort generation to ensure we never POST a null marker.
1006        sessionId = generateSessionId();
1007        MxChatInstances.setChatSession(botId, sessionId);
1008    }
1009
1010    // Wait for the page-cache nonce refresh to complete before firing the
1011    // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
1012    // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
1013    // callback guarantees we read the fresh value. See plan-c5457f.
1014    refreshNonceIfNeeded(function() {
1015    // Prepare AJAX data
1016    const ajaxData = {
1017        action: 'mxchat_handle_chat_request',
1018        message: message,
1019        session_id: sessionId,
1020        nonce: mxchatChat.nonce,
1021        current_page_url: window.location.href,
1022        current_page_title: document.title,
1023        bot_id: botId,
1024        // Pass session start timestamp so AI context matches what user sees
1025        session_start_timestamp: instance.sessionStartTimestamp || 0
1026    };
1027
1028    // Add page context if available
1029    if (pageContext) {
1030        ajaxData.page_context = JSON.stringify(pageContext);
1031    }
1032
1033    // CHECK FOR VISION FLAGS AND ADD THEM
1034    if (window.mxchatVisionProcessed) {
1035        ajaxData.vision_processed = true;
1036        ajaxData.original_user_message = window.mxchatOriginalMessage || message;
1037        ajaxData.vision_images_count = window.mxchatVisionImagesCount || 0;
1038        // Clear the flags after use
1039        window.mxchatVisionProcessed = false;
1040        window.mxchatOriginalMessage = null;
1041        window.mxchatVisionImagesCount = 0;
1042    }
1043
1044    $.ajax({
1045        url: mxchatChat.ajax_url,
1046        type: 'POST',
1047        dataType: 'json',
1048        data: ajaxData,
1049        success: function(response) {
1050            // IMMEDIATE CHAT MODE UPDATE - This should be FIRST
1051            if (response.chat_mode) {
1052                updateChatModeIndicator(response.chat_mode, botId);
1053            }
1054
1055            // Also check in data property if response is wrapped
1056            if (response.data && response.data.chat_mode) {
1057                updateChatModeIndicator(response.data.chat_mode, botId);
1058            }
1059
1060            // SECURITY FIX: Check for errors FIRST before checking for success
1061            // This ensures API errors (quota exceeded, invalid key, rate limit) are properly displayed
1062            if (response.success === false || (response.data && response.data.error_message)) {
1063                let errorMessage = "";
1064                let errorCode = "";
1065
1066                // Check various possible error locations in the response
1067                if (response.data && response.data.error_message) {
1068                    errorMessage = response.data.error_message;
1069                    errorCode = response.data.error_code || "";
1070                } else if (response.error_message) {
1071                    errorMessage = response.error_message;
1072                    errorCode = response.error_code || "";
1073                } else if (response.message) {
1074                    errorMessage = response.message;
1075                } else if (typeof response.data === 'string') {
1076                    errorMessage = response.data;
1077                } else {
1078                    // Fallback for any other unexpected response format
1079                    errorMessage = "An error occurred. Please try again or contact support.";
1080                }
1081
1082                // Handle session reset action (IP changed, session expired, etc.)
1083                // Silent reset — keep chat UI intact, just get a new session and retry
1084                if (response.data && response.data.action === 'reset_session') {
1085                    MxChatInstances.silentResetSession(botId);
1086                    // Re-send the original message with the new session (user message is already displayed)
1087                    var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1088                    if (originalMessage) {
1089                        getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1090                        var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1091                        if (shouldUseStreaming(currentModel)) {
1092                            callMxChatStream(originalMessage, function(response) {
1093                                // Final content — release aria-busy so the reply announces once (67f126).
1094                                getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
1095                            }, botId);
1096                        } else {
1097                            callMxChat(originalMessage, function(response) {
1098                                replaceLastMessage("bot", response, '', [], botId);
1099                            }, botId);
1100                        }
1101                    }
1102                    return;
1103                }
1104
1105                // Format user-friendly error message
1106                let displayMessage = errorMessage;
1107
1108                // Customize message for admin users
1109                if (mxchatChat.is_admin) {
1110                    // For admin users, show more technical details including error code
1111                    displayMessage = errorMessage + (errorCode ? " (Error code: " + errorCode + ")" : "");
1112                }
1113
1114                replaceLastMessage("bot", displayMessage, '', [], botId);
1115                return; // Exit early for errors
1116            }
1117
1118            // NOW check if this is a successful response by looking for text, html, or message fields
1119            // This preserves compatibility with your server response format
1120            if (response.text !== undefined || response.html !== undefined || response.message !== undefined ||
1121                (response.success === true && response.data && response.data.status === 'waiting_for_agent')) {
1122
1123                // Handle successful response - this is your original success handling code
1124
1125                // Handle other responses
1126                let responseText = response.text || '';
1127                let responseHtml = response.html || '';
1128                let responseMessage = response.message || '';
1129
1130                // Add PDF filename handling
1131                if (response.data && response.data.filename) {
1132                    showActivePdf(response.data.filename, botId);
1133                    var instance = MxChatInstances.get(botId);
1134                    instance.activePdfFile = response.data.filename;
1135                }
1136
1137                // Add redirect check here
1138                if (response.redirect_url) {
1139                    if (responseText) {
1140                        replaceLastMessage("bot", responseText, '', [], botId);
1141                    }
1142                    setTimeout(() => {
1143                        window.location.href = response.redirect_url;
1144                    }, 1500);
1145                    return;
1146                }
1147
1148                // Check for live agent response
1149                if (response.success && response.data && response.data.status === 'waiting_for_agent') {
1150                    removeThinkingDots(botId);
1151                    updateChatModeIndicator('agent', botId);
1152                    enableChatInput(botId);
1153                    return;
1154                }
1155
1156                // Handle the message and show notification if chat is hidden
1157                if (responseText || responseHtml || responseMessage) {
1158
1159                    // ADD RESPONSE HOOKS HERE - BEFORE DISPLAYING
1160                    if (responseText && typeof customMxChatFilter === 'function') {
1161                        responseText = customMxChatFilter(responseText, "response");
1162                    }
1163                    if (responseMessage && typeof customMxChatFilter === 'function') {
1164                        responseMessage = customMxChatFilter(responseMessage, "response");
1165                    }
1166
1167                    // Update the messages as before
1168                    if (responseText && responseHtml) {
1169                        replaceLastMessage("bot", responseText, responseHtml, [], botId);
1170                    } else if (responseText) {
1171                        replaceLastMessage("bot", responseText, '', [], botId);
1172                    } else if (responseHtml) {
1173                        replaceLastMessage("bot", "", responseHtml, [], botId);
1174                    } else if (responseMessage) {
1175                        replaceLastMessage("bot", responseMessage, '', [], botId);
1176                    }
1177
1178                    // Check if chat is hidden and show notification
1179                    var $floatingChatbot = getElement(botId, 'floating-chatbot');
1180                    if ($floatingChatbot.hasClass('hidden')) {
1181                        var $badge = getElement(botId, 'chat-notification-badge');
1182                        if ($badge.length) {
1183                            $badge.show();
1184                        }
1185                    }
1186                } else {
1187                    var emptyMsg = "I received an empty response. Please try again or contact support if this persists.";
1188                    if (response.vectorstore_error) {
1189                        emptyMsg = "I received an empty response. Debug info: " + response.vectorstore_error;
1190                    }
1191                    replaceLastMessage("bot", emptyMsg, '', [], botId);
1192                }
1193
1194                if (response.message_id) {
1195                    var instance = MxChatInstances.get(botId);
1196                    instance.lastSeenMessageId = response.message_id;
1197                }
1198
1199                return;
1200            }
1201
1202            // Fallback for truly unexpected response formats
1203            replaceLastMessage("bot", "Unexpected response format. Please try again or contact support.", '', [], botId);
1204        },
1205        error: function(xhr, status, error) {
1206            let errorMessage = "An unexpected error occurred.";
1207
1208            // Try to parse the response if it's JSON
1209            try {
1210                const responseJson = JSON.parse(xhr.responseText);
1211
1212                if (responseJson.data && responseJson.data.error_message) {
1213                    errorMessage = responseJson.data.error_message;
1214                } else if (responseJson.message) {
1215                    errorMessage = responseJson.message;
1216                }
1217            } catch (e) {
1218                // Not JSON or parsing failed, use HTTP status based messages
1219                if (xhr.status === 0) {
1220                    errorMessage = "Network error: Please check your internet connection.";
1221                } else if (xhr.status === 403) {
1222                    errorMessage = "Access denied: Your session may have expired. Please refresh the page.";
1223                } else if (xhr.status === 404) {
1224                    errorMessage = "API endpoint not found. Please contact support.";
1225                } else if (xhr.status === 429) {
1226                    errorMessage = "Too many requests. Please try again in a moment.";
1227                } else if (xhr.status >= 500) {
1228                    errorMessage = "Server error: The server encountered an issue. Please try again later.";
1229                }
1230            }
1231
1232            replaceLastMessage("bot", errorMessage, '', [], botId);
1233        }
1234    });
1235    }); // refreshNonceIfNeeded
1236}
1237
1238function callMxChatStream(message, callback, botId) {
1239    botId = botId || getMxChatBotId();
1240
1241    // Store the message in case we need to retry after session reset
1242    getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1243
1244    const currentModel = mxchatChat.model || 'gpt-5.6-sol';
1245    if (!isStreamingSupported(currentModel)) {
1246        callMxChat(message, callback, botId);
1247        return;
1248    }
1249
1250    // Get page context if contextual awareness is enabled
1251    const pageContext = getPageContext();
1252
1253    // Get instance for session start timestamp (used when persistence is OFF)
1254    var instance = MxChatInstances.get(botId);
1255
1256    // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1257    // non-string value via String(), so passing `null` would POST the literal string "null"
1258    // and land in the transcripts table as a ghost session. ensureSession() always returns
1259    // a real string even when cookies/localStorage are blocked.
1260    var streamSessionId = MxChatInstances.ensureSession(botId);
1261    if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1262        streamSessionId = generateSessionId();
1263        MxChatInstances.setChatSession(botId, streamSessionId);
1264    }
1265
1266    // Wait for the page-cache nonce refresh before constructing formData (which
1267    // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1268    refreshNonceIfNeeded(function() {
1269    const formData = new FormData();
1270    formData.append('action', 'mxchat_stream_chat');
1271    formData.append('message', message);
1272    formData.append('session_id', streamSessionId);
1273    formData.append('nonce', mxchatChat.nonce);
1274    formData.append('current_page_url', window.location.href);
1275    formData.append('current_page_title', document.title);
1276    formData.append('bot_id', botId);
1277    // Pass session start timestamp so AI context matches what user sees
1278    formData.append('session_start_timestamp', instance.sessionStartTimestamp || 0);
1279
1280    // Add page context if available
1281    if (pageContext) {
1282        formData.append('page_context', JSON.stringify(pageContext));
1283    }
1284
1285    // CHECK FOR VISION FLAGS AND ADD THEM
1286    if (window.mxchatVisionProcessed) {
1287        formData.append('vision_processed', 'true');
1288        formData.append('original_user_message', window.mxchatOriginalMessage || message);
1289        formData.append('vision_images_count', window.mxchatVisionImagesCount || '0');
1290        // Clear the flags after use
1291        window.mxchatVisionProcessed = false;
1292        window.mxchatOriginalMessage = null;
1293        window.mxchatVisionImagesCount = 0;
1294    }
1295
1296    let accumulatedContent = '';
1297    let testingDataReceived = false;
1298    let streamingStarted = false;
1299    // Server-pushed html to append as its OWN bot bubble once the stream
1300    // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1301    // deferred to [DONE] so the embed always lands BELOW the streamed text.
1302    let pendingAppendHtml = '';
1303
1304    // Abortable stream: a fresh controller per turn, keyed by bot instance.
1305    // The Stop control (send button swapped in place) aborts both the read
1306    // loop and the underlying request.
1307    var streamControl = { controller: new AbortController(), aborted: false };
1308    mxchatActiveStreams[botId] = streamControl;
1309    mxchatShowStopButton(botId);
1310
1311    fetch(mxchatChat.ajax_url, {
1312        method: 'POST',
1313        body: formData,
1314        credentials: 'same-origin',
1315        signal: streamControl.controller.signal
1316    })
1317    .then(response => {
1318        // Store the response for potential fallback handling
1319        const responseClone = response.clone();
1320
1321        if (!response.ok) {
1322            // Try to get error details from response
1323            return responseClone.json().then(errorData => {
1324                throw { isServerError: true, data: errorData };
1325            }).catch(() => {
1326                throw new Error('Network response was not ok');
1327            });
1328        }
1329
1330    // Check if response is JSON instead of streaming
1331    const contentType = response.headers.get('content-type');
1332    if (contentType && contentType.includes('application/json')) {
1333        return responseClone.json().then(data => {
1334            // IMMEDIATE CHAT MODE UPDATE for JSON response
1335            if (data.chat_mode) {
1336                updateChatModeIndicator(data.chat_mode, botId);
1337            }
1338
1339            // Check for testing panel
1340            if (window.mxchatTestPanelInstance && data.testing_data) {
1341                window.mxchatTestPanelInstance.handleTestingData(data.testing_data);
1342            }
1343
1344            // Handle the JSON response directly
1345            handleNonStreamResponse(data, callback, botId);
1346            return Promise.resolve(); // Prevent further processing
1347        });
1348    }
1349
1350        // Continue with streaming processing
1351        const reader = response.body.getReader();
1352        const decoder = new TextDecoder();
1353        let buffer = '';
1354
1355        function processStream() {
1356            reader.read().then(({ done, value }) => {
1357                if (done) {
1358                    // If streaming completed but no content was received, try to get response as fallback
1359                    if (!streamingStarted || !accumulatedContent) {
1360                        // Try to read the response as JSON
1361                        responseClone.text().then(text => {
1362                            try {
1363                                const data = JSON.parse(text);
1364                                if (data.text || data.message || data.html) {
1365                                    handleNonStreamResponse(data, callback, botId);
1366                                } else {
1367                                    // No valid data, fall back to regular call
1368                                    getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1369                                    callMxChat(message, callback, botId);
1370                                }
1371                            } catch (e) {
1372                                // Could not parse, fall back to regular call
1373                                getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1374                                callMxChat(message, callback, botId);
1375                            }
1376                        }).catch(() => {
1377                            getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1378                            callMxChat(message, callback, botId);
1379                        });
1380                        return;
1381                    }
1382
1383                    // Re-enable chat input when stream ends with content
1384                    enableChatInput(botId);
1385
1386                    // Scroll the user's last message to the top now that the
1387                    // bot's full reply has rendered (gives max reading room).
1388                    var $chatBoxDone = getElement(botId, 'chat-box');
1389                    var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1390                    if ($lastUserMsgDone.length) {
1391                        scrollElementToTop($lastUserMsgDone, botId);
1392                    }
1393
1394                    if (callback) {
1395                        callback(accumulatedContent);
1396                    }
1397                    return;
1398                }
1399
1400                buffer += decoder.decode(value, { stream: true });
1401                const lines = buffer.split('\n');
1402                buffer = lines.pop() || '';
1403
1404                for (const line of lines) {
1405                    if (line.startsWith('data: ')) {
1406                        const data = line.substring(6);
1407
1408                        if (data === '[DONE]') {
1409                            if (!accumulatedContent) {
1410                                getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1411                                callMxChat(message, callback, botId);
1412                                return;
1413                            }
1414
1415                            // Re-enable chat input after streaming completes
1416                            enableChatInput(botId);
1417
1418                            // Render any server-pushed appendix html (e.g. the
1419                            // YouTube embed) as its own bot bubble below the
1420                            // streamed text — mirrors how it is saved in the
1421                            // transcript, so history replays identically.
1422                            if (pendingAppendHtml) {
1423                                appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1424                                pendingAppendHtml = '';
1425                            }
1426
1427                            // Scroll the user's last message to the top now
1428                            // that the bot's full reply has rendered.
1429                            var $chatBoxStreamDone = getElement(botId, 'chat-box');
1430                            var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1431                            if ($lastUserMsgStreamDone.length) {
1432                                scrollElementToTop($lastUserMsgStreamDone, botId);
1433                            }
1434
1435                            if (callback) {
1436                                callback(accumulatedContent);
1437                            }
1438                            return;
1439                        }
1440
1441                        try {
1442                            const json = JSON.parse(data);
1443
1444                            // IMMEDIATE CHAT MODE UPDATE FOR STREAMING
1445                            if (json.chat_mode) {
1446                                updateChatModeIndicator(json.chat_mode, botId);
1447                            }
1448
1449                            // Handle testing data
1450                            if (json.testing_data && !testingDataReceived) {
1451                                if (window.mxchatTestPanelInstance) {
1452                                    window.mxchatTestPanelInstance.handleTestingData(json.testing_data);
1453                                    testingDataReceived = true;
1454                                }
1455                            }
1456                            // Handle content streaming
1457                            else if (json.content) {
1458                                streamingStarted = true;
1459                                accumulatedContent += json.content;
1460                                updateStreamingMessage(accumulatedContent, botId);
1461                            }
1462                            // Stash appendix html (e.g. video embed) for [DONE]
1463                            else if (json.append_html) {
1464                                pendingAppendHtml = json.append_html;
1465                            }
1466                            // Server-side final pass changed the assembled text
1467                            // (ffef6f: dead-link stripping) — swap the rendered
1468                            // bubble for the validated version. Arrives at most
1469                            // once, just before [DONE].
1470                            else if (json.replace_content) {
1471                                streamingStarted = true;
1472                                accumulatedContent = json.replace_content;
1473                                updateStreamingMessage(accumulatedContent, botId);
1474                            }
1475                            // Handle complete response in stream (fallback response)
1476                            else if (json.text || json.message || json.html) {
1477                                handleNonStreamResponse(json, callback, botId);
1478                                return;
1479                            }
1480                            // Handle errors
1481                            else if (json.error) {
1482
1483                                // Get error message from various possible fields
1484                                let errorMessage = json.error_message || json.message || json.text ||
1485                                    (typeof json.error === 'string' ? json.error : 'An error occurred. Please try again.');
1486
1487                                // Re-enable chat input on error
1488                                enableChatInput(botId);
1489
1490                                // Display the error directly in the chat
1491                                replaceLastMessage("bot", errorMessage, '', [], botId);
1492
1493                                if (callback) {
1494                                    callback(errorMessage);
1495                                }
1496                                return;
1497                            }
1498                        } catch (e) {
1499                            // SSE data parsing error - silently continue
1500                        }
1501                    }
1502                }
1503
1504                processStream();
1505            }).catch(streamError => {
1506                if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1507                getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1508                callMxChat(message, callback, botId);
1509            });
1510        }
1511
1512        processStream();
1513    })
1514        .catch(error => {
1515            if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1516            // Check if we have server error data with chat mode
1517            if (error && error.isServerError && error.data) {
1518                // Check for chat mode in error data
1519                if (error.data.chat_mode) {
1520                    updateChatModeIndicator(error.data.chat_mode, botId);
1521                }
1522
1523                handleNonStreamResponse(error.data, callback, botId);
1524            } else {
1525                // Only fall back to regular call if we don't have any response data
1526                getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1527                callMxChat(message, callback, botId);
1528            }
1529        });
1530    }); // refreshNonceIfNeeded
1531}
1532
1533// Helper function to handle non-streaming responses
1534function handleNonStreamResponse(data, callback, botId) {
1535    botId = botId || 'default';
1536
1537    // IMMEDIATE CHAT MODE UPDATE FOR NON-STREAMING RESPONSES
1538    if (data.chat_mode) {
1539        updateChatModeIndicator(data.chat_mode, botId);
1540    }
1541
1542    // Also check in data property if response is wrapped
1543    if (data.data && data.data.chat_mode) {
1544        updateChatModeIndicator(data.data.chat_mode, botId);
1545    }
1546
1547    // NOTE: Don't remove temporary message here - let replaceLastMessage handle it
1548    // This prevents a visual gap between thinking dots disappearing and content appearing
1549
1550    // SECURITY FIX: Check for errors FIRST
1551    if (data.success === false || (data.data && data.data.error_message)) {
1552        let errorMessage = "";
1553        let errorCode = "";
1554
1555        // Check various possible error locations
1556        if (data.data && data.data.error_message) {
1557            errorMessage = data.data.error_message;
1558            errorCode = data.data.error_code || "";
1559        } else if (data.error_message) {
1560            errorMessage = data.error_message;
1561            errorCode = data.error_code || "";
1562        } else if (data.message) {
1563            errorMessage = data.message;
1564        } else if (typeof data.data === 'string') {
1565            errorMessage = data.data;
1566        } else {
1567            errorMessage = "An error occurred. Please try again or c
1567ontact support.";
1568        }
1569
1570        // Handle session reset action (IP changed, session expired, etc.)
1571        // Silent reset — keep chat UI intact, just get a new session and retry
1572        if (data.data && data.data.action === 'reset_session') {
1573            MxChatInstances.silentResetSession(botId);
1574            // Re-send the original message with the new session (user message is already displayed)
1575            var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1576            if (originalMessage) {
1577                getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1578                var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1579                if (shouldUseStreaming(currentModel)) {
1580                    callMxChatStream(originalMessage, callback, botId);
1581                } else {
1582                    callMxChat(originalMessage, callback, botId);
1583                }
1584            }
1585            return;
1586        }
1587
1588        // Format user-friendly error message
1589        let displayMessage = errorMessage;
1590        if (mxchatChat.is_admin) {
1591            displayMessage = errorMessage + (errorCode ? " (Error code: " + errorCode + ")" : "");
1592        }
1593
1594        replaceLastMessage("bot", displayMessage, '', [], botId);
1595
1596        if (callback) {
1597            callback('');
1598        }
1599        return; // Exit early for errors
1600    }
1601
1602    // Check for live agent response
1603    if (data.success && data.data && data.data.status === 'waiting_for_agent') {
1604        removeThinkingDots(botId);
1605        // Also remove any leftover bot-message that lost its temporary-message class
1606        var $chatBox = getElement(botId, 'chat-box');
1607        $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
1608        updateChatModeIndicator('agent', botId);
1609        enableChatInput(botId);
1610        if (callback) {
1611            callback('');
1612        }
1613        return;
1614    }
1615
1616    // Handle different response formats
1617    if (data.text || data.html || data.message) {
1618
1619        // Apply response hooks
1620        if (data.text && typeof customMxChatFilter === 'function') {
1621            data.text = customMxChatFilter(data.text, "response");
1622        }
1623        if (data.message && typeof customMxChatFilter === 'function') {
1624            data.message = customMxChatFilter(data.message, "response");
1625        }
1626
1627        // Display the response
1628        if (data.text && data.html) {
1629            replaceLastMessage("bot", data.text, data.html, [], botId);
1630        } else if (data.text) {
1631            replaceLastMessage("bot", data.text, '', [], botId);
1632        } else if (data.html) {
1633            replaceLastMessage("bot", "", data.html, [], botId);
1634        } else if (data.message) {
1635            replaceLastMessage("bot", data.message, '', [], botId);
1636        }
1637    }
1638
1639    // Handle other response properties
1640    if (data.data && data.data.filename) {
1641        showActivePdf(data.data.filename, botId);
1642        var instance = MxChatInstances.get(botId);
1643        instance.activePdfFile = data.data.filename;
1644    }
1645
1646    if (data.redirect_url) {
1647        setTimeout(() => {
1648            window.location.href = data.redirect_url;
1649        }, 1500);
1650    }
1651
1652    // Ensure chat input is re-enabled (safety net for edge cases)
1653    enableChatInput(botId);
1654
1655    if (callback) {
1656        callback(data.text || data.message || '');
1657    }
1658}
1659
1660// Enhanced updateChatModeIndicator function for immediate DOM updates
1661function updateChatModeIndicator(mode, botId) {
1662    botId = botId || 'default';
1663    const indicator = getElementDOM(botId, 'chat-mode-indicator');
1664    if (indicator) {
1665        const oldText = indicator.textContent;
1666
1667        if (mode === 'agent') {
1668            indicator.textContent = 'Live Agent';
1669            startPolling(botId);
1670        } else {
1671            // Everything else is AI mode
1672            const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent';
1673            indicator.textContent = customAiText;
1674            stopPolling(botId);
1675        }
1676
1677        // Force immediate DOM update and reflow
1678        if (oldText !== indicator.textContent) {
1679            // Force a reflow to ensure the change is visible immediately
1680            indicator.style.display = 'none';
1681            indicator.offsetHeight; // Trigger reflow
1682            indicator.style.display = '';
1683
1684            // Double-check after a brief moment to ensure the change stuck
1685            setTimeout(() => {
1686                if (mode === 'agent' && indicator.textContent !== 'Live Agent') {
1687                    indicator.textContent = 'Live Agent';
1688                } else if (mode !== 'agent' && indicator.textContent === 'Live Agent') {
1689                    const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent';
1690                    indicator.textContent = customAiText;
1691                }
1692            }, 50);
1693        }
1694    }
1695}
1696
1697// Function to update message during streaming
1698function updateStreamingMessage(content, botId) {
1699    botId = botId || 'default';
1700
1701    // ADD RESPONSE HOOK FOR REAL-TIME STREAMING
1702    if (typeof customMxChatFilter === 'function') {
1703        content = customMxChatFilter(content, "response");
1704    }
1705
1706    const formattedContent = linkify(content);
1707
1708    // Find the temporary message in this bot's chat box
1709    var $chatBox = getElement(botId, 'chat-box');
1710    const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1711
1712    if (tempMessage.length) {
1713        // aria-busy=true for the whole stream: the bubble is rewritten on
1714        // every chunk, and without busy a polite live region announces those
1715        // rewrites continuously. Flipped false once the reply is final, so
1716        // assistive tech announces the completed message ONCE (plan 67f126).
1717        if (tempMessage.attr('aria-busy') !== 'true') {
1718            tempMessage.attr('aria-busy', 'true');
1719        }
1720        // Update existing message
1721        tempMessage.html(formattedContent);
1722    } else {
1723        // Create new temporary message if it doesn't exist
1724        appendMessage("bot", content, '', [], true, botId);
1725    }
1726}
1727
1728function isStreamingSupported(model) {
1729    if (!model) return false;
1730
1731    const modelPrefix = model.split('-')[0].toLowerCase();
1732
1733    // Support streaming for OpenAI, Claude, Grok, DeepSeek, and OpenRouter models
1734    const isSupported = modelPrefix === 'gpt' || 
1735                        modelPrefix === 'o1' || 
1736                        modelPrefix === 'claude' || 
1737                        modelPrefix === 'grok' || 
1738                        modelPrefix === 'deepseek' || 
1739                        model === 'openrouter';  // Add this line - check full model name for OpenRouter
1740    
1741    return isSupported;
1742}
1743
1744// Update the event handlers to use the correct function names (using event delegation)
1745// Use class-based selectors for multi-instance support
1746$(document).on('click', '.send-button', function() {
1747    var botId = getBotIdFromElement(this);
1748    // While a response is streaming the button is a Stop control.
1749    if (this.classList.contains('mxchat-stop-mode')) {
1750        // Same click that just started this stream (an add-on's direct handler
1751        // ran before this delegated one) — not a Stop press. See
1752        // mxchatShowStopButton for the full story (plan-4bba64).
1753        if (this.__mxchatStopJustShown) return;
1754        mxchatStopStreaming(botId);
1755        return;
1756    }
1757    var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1758    if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1759        disableChatInput(botId);
1760    }
1761    sendMessage(botId);
1762});
1763
1764// Override enter key handler (using event delegation)
1765$(document).on('keypress', '.chat-input', function(e) {
1766    if (e.which == 13 && !e.shiftKey) {
1767        e.preventDefault();
1768        var botId = getBotIdFromElement(this);
1769        var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1770        if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1771            disableChatInput(botId);
1772        }
1773        sendMessage(botId);
1774    }
1775});
1776
1777// Chat input character counter + soft limit feedback (plan 7091a2).
1778// Language-neutral: numbers + color only, no translatable strings. The counter
1779// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1780// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1781// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1782function mxchatUpdateCharCounter(inputEl) {
1783    if (!inputEl || !inputEl.closest) return;
1784    var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1785    var container = inputEl.closest('.input-container');
1786    if (!container || !max || max <= 0) return;
1787    var counter = container.querySelector('.mxchat-char-counter');
1788    if (!counter) return;
1789    var len = inputEl.value.length;
1790    var ratio = len / max;
1791    var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1792    var cur = counter.querySelector('.mxchat-char-counter-current');
1793    if (cur) cur.textContent = len;
1794    var warn = ratio >= nearThreshold && len < max;
1795    var full = len >= max;
1796    counter.classList.toggle('is-visible', ratio >= nearThreshold);
1797    counter.classList.toggle('is-warn', warn);
1798    counter.classList.toggle('is-full', full);
1799    container.classList.toggle('mxchat-input-near-limit', warn);
1800    container.classList.toggle('mxchat-input-at-limit', full);
1801}
1802
1803function mxchatBumpInput(inputEl) {
1804    var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1805    if (!container) return;
1806    container.classList.remove('mxchat-input-bump');
1807    void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1808    container.classList.add('mxchat-input-bump');
1809    clearTimeout($(container).data('mxchatBumpTimeout'));
1810    var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1811    $(container).data('mxchatBumpTimeout', t);
1812}
1813
1814// Live counter update on every input.
1815$(document).on('input', '.chat-input', function() {
1816    mxchatUpdateCharCounter(this);
1817});
1818
1819// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1820// rejected at the cap (maxlength silently swallows it otherwise).
1821$(document).on('keydown', '.chat-input', function(e) {
1822    var max = parseInt(this.getAttribute('maxlength'), 10);
1823    if (!max || max <= 0 || this.value.length < max) return;
1824    if (e.ctrlKey || e.metaKey || e.altKey) return;
1825    // A single printable char with no selection to overwrite WILL be rejected.
1826    if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1827        mxchatBumpInput(this);
1828    }
1829});
1830
1831// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1832$(document).on('paste', '.chat-input', function() {
1833    var el = this;
1834    var max = parseInt(el.getAttribute('maxlength'), 10);
1835    if (!max || max <= 0) return;
1836    setTimeout(function() {
1837        mxchatUpdateCharCounter(el);
1838        if (el.value.length >= max) mxchatBumpInput(el);
1839    }, 0);
1840});
1841
1842// Builds the list of overflow-menu items for a given bot.
1843// Adding a future item is one push to this array — do NOT hardcode "only download."
1844function mxchatGetHeaderMenuItems(botId) {
1845    var items = [];
1846    var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1847
1848    // The `print_button_*` keys still gate this item for back-compat with
1849    // existing user options. The action is now a transcript download, not print.
1850    if (settings.print_button_enabled === 'on') {
1851        items.push({
1852            id: 'download-transcript',
1853            label: settings.print_button_label || 'Download Transcript',
1854            icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
1855            action: function() {
1856                mxchatDownloadTranscript(botId);
1857            }
1858        });
1859    }
1860
1861    // "Start new chat" — surfaces the EXISTING per-conversation reset
1862    // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1863    // without the site owner disabling chat persistence globally. Default OFF;
1864    // gated by the reset_chat_enabled option. plan ac2e81.
1865    if (settings.reset_chat_enabled === 'on') {
1866        items.push({
1867            id: 'reset-chat',
1868            label: settings.reset_chat_label || 'Start new chat',
1869            icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>',
1870            action: function() {
1871                var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1872                if (window.confirm(confirmMsg)) {
1873                    MxChatInstances.resetChatSession(botId);
1874                }
1875            }
1876        });
1877    }
1878
1879    return items;
1880}
1881
1882// Builds a clean markdown transcript of the current conversation and triggers
1883// a file download. Used by the "Download Transcript" menu item.
1884function mxchatDownloadTranscript(botId) {
1885    var $chatBox = getElement(botId, 'chat-box');
1886    if (!$chatBox || !$chatBox.length) return;
1887
1888    var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1889    var headerTitle = settings.print_header_title || 'Chat transcript';
1890    var now = new Date();
1891    var stamp = now.toLocaleString();
1892
1893    var lines = [];
1894    lines.push('# ' + headerTitle);
1895    lines.push('');
1896    lines.push('Exported: ' + stamp);
1897    lines.push('');
1898    lines.push('---');
1899    lines.push('');
1900
1901    $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1902        var $msg = $(this);
1903        // Skip thinking placeholders and any in-flight temporary messages.
1904        if ($msg.find('.thinking-dots').length) return;
1905        if ($msg.hasClass('temporary-message')) return;
1906
1907        var sender;
1908        if ($msg.hasClass('user-message')) sender = 'User';
1909        else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1910        else sender = 'AI Agent';
1911
1912        // Strip interactive UI from the cloned message so we get the conversation text.
1913        var $clone = $msg.clone();
1914        $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1915        var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1916        if (!text) return;
1917
1918        lines.push('**' + sender + '**');
1919        lines.push('');
1920        lines.push(text);
1921        lines.push('');
1922    });
1923
1924    var content = lines.join('\n');
1925    var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1926    var fname = 'mxchat-transcript-' + iso + '.md';
1927    var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1928    var url = URL.createObjectURL(blob);
1929    var a = document.createElement('a');
1930    a.href = url;
1931    a.download = fname;
1932    a.style.display = 'none';
1933    document.body.appendChild(a);
1934    a.click();
1935    setTimeout(function() {
1936        if (a.parentNode) a.parentNode.removeChild(a);
1937        URL.revokeObjectURL(url);
1938    }, 100);
1939}
1940
1941// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1942// on the menu wrap, so the dropdown matches whatever paints the bubble —
1943// saved options, AI theme CSS, or the mxchat-theme add-on.
1944function mxchatSyncMenuColors(botId, $wrap) {
1945    if (!$wrap || !$wrap.length) return;
1946    var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1947    if (!$bot.length) return;
1948    var cs = window.getComputedStyle($bot[0]);
1949    if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1950        $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1951    }
1952    // Bot text color usually lives on a child div, not .bot-message itself.
1953    var $textChild = $bot.find('[style*="color"]').first();
1954    var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1955    if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1956}
1957
1958// Renders (or re-renders) the item list for one menu wrap. Split out of
1959// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1960// rebuild items + trigger visibility WITHOUT re-binding the one-time
1961// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1962// a rebuild before init (never happens, but harmless) just skips it.
1963function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1964    var $trigger = $wrap.find('.mxchat-menu-trigger');
1965    var $menu = $wrap.find('.mxchat-header-menu');
1966    var items = mxchatGetHeaderMenuItems(botId);
1967
1968    $menu.empty();
1969
1970    if (!items.length) {
1971        $trigger.hide();
1972        $menu.hide();
1973        return;
1974    }
1975
1976    // Clear any inline display:none a previous zero-item render left behind —
1977    // open/close visibility is governed by the hidden prop + is-open class.
1978    $trigger.css('display', '');
1979    $menu.css('display', '');
1980
1981    items.forEach(function(item, idx) {
1982        var $btn = $('<button>', {
1983            type: 'button',
1984            'class': 'mxchat-menu-item',
1985            'role': 'menuitem',
1986            'tabindex': '-1',
1987            'data-menu-id': item.id,
1988            html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1989                  '<span class="mxchat-menu-item-label"></span>'
1990        });
1991        $btn.find('.mxchat-menu-item-label').text(item.label);
1992        $btn.on('click', function(e) {
1993            e.preventDefault();
1994            e.stopPropagation();
1995            if (closeMenuFn) closeMenuFn();
1996            try { item.action(); } catch (err) { /* no-op */ }
1997        });
1998        $menu.append($btn);
1999    });
2000}
2001
2002// Re-render every menu on the page after a dynamic-settings merge
2003// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
2004// swapping items under the user mid-interaction yanks focus — and the
2005// rebuild runs when it closes instead (closeMenu checks the pending flag).
2006function mxchatRebuildHeaderMenus() {
2007    $('.mxchat-header-menu-wrap').each(function() {
2008        var $wrap = $(this);
2009        var botId = $wrap.data('bot-id');
2010        if (!botId) return;
2011        if (!$wrap.data('mxchatMenuReady')) {
2012            mxchatInitHeaderMenu(botId);
2013            return;
2014        }
2015        if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
2016            $wrap.data('mxchatMenuRebuildPending', true);
2017            return;
2018        }
2019        mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
2020    });
2021}
2022
2023// One-time per-widget init: renders menu items, wires open/close,
2024// outside-click, Escape, and arrow-key navigation. If no items, hides the
2025// trigger. Wiring happens even when there are zero items at init, so a
2026// later dynamic-settings rebuild that adds items has a working trigger.
2027function mxchatInitHeaderMenu(botId) {
2028    var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
2029    if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
2030
2031    var $trigger = $wrap.find('.mxchat-menu-trigger');
2032    var $menu = $wrap.find('.mxchat-header-menu');
2033
2034    // Initial color sync — covers normal page load.
2035    mxchatSyncMenuColors(botId, $wrap);
2036
2037    function openMenu() {
2038        // Re-sync each open in case the active theme changed since init.
2039        mxchatSyncMenuColors(botId, $wrap);
2040        $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
2041        $trigger.attr('aria-expanded', 'true');
2042        // Focus the first item for keyboard users
2043        setTimeout(function() {
2044            $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
2045        }, 0);
2046    }
2047    function closeMenu(returnFocus) {
2048        $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
2049        $trigger.attr('aria-expanded', 'false');
2050        $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
2051        if (returnFocus) $trigger.trigger('focus');
2052        // A dynamic-settings rebuild that arrived while the menu was open
2053        // was deferred (mxchatRebuildHeaderMenus) — run it now.
2054        if ($wrap.data('mxchatMenuRebuildPending')) {
2055            $wrap.removeData('mxchatMenuRebuildPending');
2056            mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2057        }
2058    }
2059
2060    // Toggle on trigger click — stop propagation so the .chatbot-top-bar
2061    // click-to-collapse handler does not fire.
2062    $trigger.on('click', function(e) {
2063        e.preventDefault();
2064        e.stopPropagation();
2065        if ($menu.hasClass('is-open')) closeMenu();
2066        else openMenu();
2067    });
2068
2069    // Don't let clicks inside the menu bubble to the top-bar collapse handler.
2070    $menu.on('click', function(e) {
2071        e.stopPropagation();
2072    });
2073
2074    // Outside click closes the menu.
2075    $(document).on('click.mxchatMenu-' + botId, function(e) {
2076        if (!$menu.hasClass('is-open')) return;
2077        if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
2078        closeMenu();
2079    });
2080
2081    // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
2082    $menu.on('keydown', '.mxchat-menu-item', function(e) {
2083        var $items = $menu.find('.mxchat-menu-item');
2084        var idx = $items.index(this);
2085        if (e.key === 'Escape') {
2086            e.preventDefault();
2087            closeMenu(true);
2088        } else if (e.key === 'ArrowDown') {
2089            e.preventDefault();
2090            var $next = $items.eq((idx + 1) % $items.length);
2091            $items.attr('tabindex', '-1');
2092            $next.attr('tabindex', '0').trigger('focus');
2093        } else if (e.key === 'ArrowUp') {
2094            e.preventDefault();
2095            var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
2096            $items.attr('tabindex', '-1');
2097            $prev.attr('tabindex', '0').trigger('focus');
2098        } else if (e.key === 'Enter' || e.key === ' ') {
2099            e.preventDefault();
2100            $(this).trigger('click');
2101        }
2102    });
2103    $trigger.on('keydown', function(e) {
2104        if (e.key === 'Escape' && $menu.hasClass('is-open')) {
2105            e.preventDefault();
2106            closeMenu(true);
2107        } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
2108            e.preventDefault();
2109            openMenu();
2110        }
2111    });
2112
2113    // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
2114    // then do the initial item render.
2115    $wrap.data('mxchatMenuClose', closeMenu);
2116    mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2117
2118    $wrap.data('mxchatMenuReady', true);
2119}
2120
2121// Initialize header menus for every rendered widget on DOM ready.
2122$(function() {
2123    $('.mxchat-header-menu-wrap').each(function() {
2124        var botId = $(this).data('bot-id');
2125        if (botId) mxchatInitHeaderMenu(botId);
2126    });
2127
2128    // Embedded (non-floating) widgets are open from the moment the page
2129    // renders — refresh dynamic settings at init (plan-32db95). Floating
2130    // widgets refresh on first launcher open instead.
2131    var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
2132        return !$(this).closest('.floating-chatbot').length;
2133    }).length > 0;
2134    if (hasEmbeddedWidget) {
2135        mxchatRefreshDynamicSettings();
2136    }
2137});
2138
2139function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
2140    try {
2141        // Determine styles based on sender type
2142        let messageClass, bgColor, fontColor;
2143
2144        if (sender === "user") {
2145            messageClass = "user-message";
2146            bgColor = userMessageBgColor;
2147            fontColor = userMessageFontColor;
2148            // Only sanitize user input
2149            messageText = sanitizeUserInput(messageText);
2150        } else if (sender === "agent") {
2151            messageClass = "agent-message";
2152            bgColor = liveAgentMessageBgColor;
2153            fontColor = liveAgentMessageFontColor;
2154        } else {
2155            messageClass = "bot-message";
2156            bgColor = botMessageBgColor;
2157            fontColor = botMessageFontColor;
2158        }
2159
2160        const messageDiv = $('<div>')
2161            .addClass(messageClass)
2162            .attr('dir', 'auto');
2163
2164        // Only apply inline colors if AI theme is not active (let CSS handle it)
2165        var skipColors = shouldSkipInlineColors(botId);
2166        if (skipColors) {
2167            messageDiv.css({
2168                'margin-bottom': '1em'
2169            });
2170        } else {
2171            messageDiv.css({
2172                'background': bgColor,
2173                'color': fontColor,
2174                'margin-bottom': '1em'
2175            });
2176        }
2177
2178        // Process the message content - always run linkify to convert markdown
2179        // links and format text. linkify() handles existing HTML safely via
2180        // negative lookaheads that skip URLs already inside <a> tags.
2181        let fullMessage = linkify(messageText);
2182
2183        // Add images if provided
2184        if (images && images.length > 0) {
2185            fullMessage += '<div class="image-gallery" dir="auto">';
2186            images.forEach(img => {
2187                const safeTitle = sanitizeUserInput(img.title);
2188                const safeUrl = encodeURI(img.image_url);
2189                const safeThumbnail = encodeURI(img.thumbnail_url);
2190
2191                fullMessage += `
2192                    <div style="margin-bottom: 10px;">
2193                        <strong>${safeTitle}</strong><br>
2194                        <a href="${safeUrl}" target="_blank">
2195                            <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
2196                        </a>
2197                    </div>`;
2198            });
2199            fullMessage += '</div>';
2200        }
2201
2202        // Append HTML content if provided
2203        if (messageHtml && sender !== "user") {
2204            // Only add line breaks if there's actual text content before the HTML
2205            if (fullMessage && fullMessage.trim()) {
2206                fullMessage += '<br><br>' + messageHtml;
2207            } else {
2208                fullMessage = messageHtml;
2209            }
2210        }
2211
2212        messageDiv.html(fullMessage);
2213
2214        if (isTemporary) {
2215            // In-flight bubble: hold aria-busy so the live region stays quiet
2216            // until the content is finalized (plan 67f126).
2217            messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
2218        }
2219
2220        // Append to the correct chatbot instance's chat-box
2221        var $chatBox = getElement(botId, 'chat-box');
2222        messageDiv.hide().appendTo($chatBox).fadeIn(300, function() {
2223            // FIXED: Use event delegation for link tracking
2224            if (sender === "bot" || sender === "agent") {
2225                attachLinkTracking(messageDiv, messageText, botId);
2226            }
2227
2228            if (sender === "bot") {
2229                const lastUserMessage = $chatBox.find('.user-message').last();
2230                if (lastUserMessage.length) {
2231                    scrollElementToTop(lastUserMessage, botId);
2232                }
2233            }
2234
2235            if ((sender === "bot" || sender === "agent") && !isTemporary) {
2236                if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2237            }
2238        });
2239
2240        if (messageText.id) {
2241            var instance = MxChatInstances.get(botId);
2242            instance.lastSeenMessageId = messageText.id;
2243            hideNotification(botId);
2244        }
2245    } catch (error) {
2246        // Error rendering message - silently continue
2247    }
2248}
2249
2250//   Helper function to attach link tracking with proper event handling
2251function attachLinkTracking(messageDiv, messageText, botId) {
2252    botId = botId || 'default';
2253    // Use a slight delay to ensure DOM is ready
2254    setTimeout(function() {
2255        const links = messageDiv.find('a[href]').not('[data-tracked]');
2256
2257        links.each(function() {
2258            const $link = $(this);
2259            const originalHref = $link.attr('href');
2260
2261            // Mark as tracked to avoid duplicate handlers
2262            $link.attr('data-tracked', 'true');
2263
2264            // Only track external URLs
2265            if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
2266                // Remove any existing click handlers first
2267                $link.off('click.tracking');
2268
2269                // Add new click handler with namespace
2270                $link.on('click.tracking', function(e) {
2271                    e.preventDefault();
2272                    e.stopPropagation();
2273
2274                    const messageContext = typeof messageText === 'string'
2275                        ? messageText.substring(0, 200)
2276                        : '';
2277
2278                    // Track the click
2279                    $.ajax({
2280                        url: mxchatChat.ajax_url,
2281                        type: 'POST',
2282                        data: {
2283                            action: 'mxchat_track_url_click',
2284                            session_id: getChatSession(botId),
2285                            url: originalHref,
2286                            message_context: messageContext,
2287                            nonce: mxchatChat.nonce
2288                        },
2289                        complete: function() {
2290                            // Always redirect, even if tracking fails
2291                            if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
2292                                window.open(originalHref, '_blank');
2293                            } else {
2294                                window.location.href = originalHref;
2295                            }
2296                        }
2297                    });
2298
2299                    return false; // Extra insurance to prevent default
2300                });
2301            }
2302        });
2303    }, 100); // Small delay to ensure DOM is ready
2304}
2305
2306function replaceLastMessage(sender, responseText, responseHtml = '', images = [], botId = 'default') {
2307    var messageClass = sender === "user" ? "user-message" : sender === "agent" ? "agent-message" : "bot-message";
2308    var $chatBox = getElement(botId, 'chat-box');
2309    var lastMessageDiv = $chatBox.find('.bot-message.temporary-message, .agent-message.temporary-message').last();
2310
2311    // Determine styles
2312    let bgColor, fontColor;
2313    if (sender === "user") {
2314        bgColor = userMessageBgColor;
2315        fontColor = userMessageFontColor;
2316    } else if (sender === "agent") {
2317        bgColor = liveAgentMessageBgColor;
2318        fontColor = liveAgentMessageFontColor;
2319    } else {
2320        bgColor = botMessageBgColor;
2321        fontColor = botMessageFontColor;
2322    }
2323
2324    // Always run linkify to convert markdown links and format text.
2325    // linkify() already handles existing HTML (its URL patterns use negative lookaheads
2326    // to avoid double-processing URLs that are already inside <a> tags).
2327    var fullMessage = linkify(responseText);
2328
2329    if (responseHtml) {
2330        // Only add line breaks if there's actual text content before the HTML
2331        if (fullMessage && fullMessage.trim()) {
2332            fullMessage += '<br><br>' + responseHtml;
2333        } else {
2334            fullMessage = responseHtml;
2335        }
2336    }
2337
2338    if (images.length > 0) {
2339        fullMessage += '<div class="image-gallery" dir="auto">';
2340        images.forEach(img => {
2341            const safeTitle = sanitizeUserInput(img.title);
2342            const safeUrl = escapeAttr(img.image_url);
2343            const safeThumbnail = escapeAttr(img.thumbnail_url);
2344            fullMessage += `
2345                <div style="margin-bottom: 10px;">
2346                    <strong>${safeTitle}</strong><br>
2347                    <a href="${safeUrl}" target="_blank">
2348                        <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
2349                    </a>
2350                </div>`;
2351        });
2352        fullMessage += '</div>';
2353    }
2354
2355    if (lastMessageDiv.length) {
2356        // Replace content immediately to prevent visual gap between thinking dots and response
2357        // aria-busy released AFTER the final content is set, so the live region
2358        // announces the finished message once (plan 67f126).
2359        lastMessageDiv
2360            .html(fullMessage)
2361            .removeClass('bot-message user-message temporary-message')
2362            .addClass(messageClass)
2363            .attr('dir', 'auto')
2364            .attr('aria-busy', 'false');
2365
2366        // Only apply inline colors if AI theme is not active (let CSS handle it)
2367        var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2368        if (!skipColors) {
2369            lastMessageDiv.css({
2370                'background-color': bgColor,
2371                'color': fontColor,
2372            });
2373        }
2374
2375        // Handle link tracking and scroll
2376        if (sender === "bot" || sender === "agent") {
2377            attachLinkTracking(lastMessageDiv, responseText, botId);
2378
2379            const lastUserMessage = $chatBox.find('.user-message').last();
2380            if (lastUserMessage.length) {
2381                scrollElementToTop(lastUserMessage, botId);
2382            }
2383            // Show notification if chat is hidden
2384            var $floatingChatbot = getElement(botId, 'floating-chatbot');
2385            if ($floatingChatbot.hasClass('hidden')) {
2386                showNotification(botId);
2387            }
2388        }
2389
2390        // Re-enable chat input after response is displayed
2391        enableChatInput(botId);
2392
2393        if (sender === "bot" || sender === "agent") {
2394            if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2395        }
2396    } else {
2397        appendMessage(sender, responseText, responseHtml, images, false, botId);
2398        // Re-enable chat input after response is displayed
2399        enableChatInput(botId);
2400    }
2401}
2402
2403
2404    function appendThinkingMessage(botId) {
2405        botId = botId || 'default';
2406
2407        // Don't show thinking dots in live agent mode - message is just forwarded to a human
2408        var indicator = getElementDOM(botId, 'chat-mode-indicator');
2409        if (indicator && indicator.textContent === 'Live Agent') {
2410            return;
2411        }
2412
2413        var $chatBox = getElement(botId, 'chat-box');
2414
2415        // Remove any existing thinking dots in this bot's chat first
2416        $chatBox.find('.thinking-dots').remove();
2417
2418        // Check if we should skip inline colors (AI theme is active)
2419        var skipColors = shouldSkipInlineColors(botId);
2420
2421        // Retrieve the bot message font color and background color
2422        var botMessageFontColor = mxchatChat.bot_message_font_color;
2423        var botMessageBgColor = mxchatChat.bot_message_bg_color;
2424
2425        // Build thinking dots HTML - skip inline colors if AI theme is active
2426        // The dots are decorative; the sr-only span is what the live region
2427        // announces for the waiting state (plan 67f126). Server-localized
2428        // string — safe to inject (esc_html__ output, no user content).
2429        var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
2430        var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2431        var thinkingHtml = '<span class="sr-only">
2431' + srThinking + '</span>' +
2432                           '<div class="thinking-dots-container" aria-hidden="true">' +
2433                           '<div class="thinking-dots">' +
2434                           '<span class="dot"' + dotStyle + '></span>' +
2435                           '<span class="dot"' + dotStyle + '></span>' +
2436                           '<span class="dot"' + dotStyle + '></span>' +
2437                           '</div>' +
2438                           '</div>';
2439
2440        // Append the thinking dots to this bot's chat container - skip inline colors if AI theme is active
2441        var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + '; color: ' + botMessageFontColor + ';"';
2442        $chatBox.append('<div class="bot-message temporary-message"' + messageStyle + '>' + thinkingHtml + '</div>');
2443        scrollToBottom(botId);
2444    }
2445
2446    function removeThinkingDots(botId) {
2447        botId = botId || 'default';
2448        var $chatBox = getElement(botId, 'chat-box');
2449        // Remove by temporary-message class first, then fall back to any bot-message containing thinking dots
2450        $chatBox.find('.thinking-dots').closest('.temporary-message').remove();
2451        $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
2452    }
2453
2454    // ====================================
2455    // TEXT FORMATTING & PROCESSING
2456    // ====================================
2457    
2458    function linkify(inputText) {
2459    if (!inputText) {
2460        return '';
2461    }
2462    
2463    // Helper function to check if URL is already encoded
2464    function isUrlEncoded(url) {
2465        // Check for % followed by exactly 2 hex digits
2466        return /%[0-9a-fA-F]{2}/.test(url);
2467    }
2468    
2469    // Helper function to safely encode URLs only if needed, then make the
2470    // result attribute-safe. "Don't double-encode an already-encoded URL"
2471    // used to mean "return it untouched" — so a literal quote riding next
2472    // to a %20 reached href="…" intact and closed the attribute
2473    // (plan 2db182). Every href this function feeds now goes through
2474    // escapeAttr(), whether or not the URL was already percent-encoded.
2475    function safeEncodeUrl(url) {
2476        const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2477        return escapeAttr(encoded);
2478    }
2479    
2480    // Take backslash escapes and code (fenced + inline) out of the text FIRST
2481    // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a
2482    // backtick could not protect an underscore and \_ was not an escape at
2483    // all; the header rule also ran over fenced code. Each escape becomes a
2484    // placeholder carrying its literal character, each code span a
2485    // placeholder carrying its rendered HTML; both are restored below, after
2486    // the styling rules have run, so nothing downstream changes.
2487    const literals = protectLiterals(inputText);
2488    let processedText = literals.text;
2489
2490    // Process markdown headers
2491    processedText = formatMarkdownHeaders(processedText);
2492
2493    // Process text styling (bold, italic, strikethrough)
2494    processedText = formatTextStyling(processedText);
2495
2496    // Legacy raw <?php ... ?> blocks (fenced + inline code were already
2497    // rendered inside protectLiterals) — still BEFORE processing links
2498    processedText = formatCodeBlocks(processedText);
2499
2500    // Put the rendered code and the escaped characters back
2501    processedText = literals.restore(processedText);
2502
2503    // Process markdown tables BEFORE converting newlines to paragraphs
2504    processedText = formatMarkdownTables(processedText);
2505
2506    // NOW convert to paragraphs
2507    processedText = convertNewlinesToBreaks(processedText);
2508    
2509    // IMPORTANT: Handle citation-style brackets FIRST [URL] 
2510    // This prevents them from being processed as markdown links
2511    // Match [URL] where URL is a complete URL in square brackets (common in AI citations)
2512    processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
2513        // Clean the URL of any trailing punctuation
2514        let cleanUrl = url.replace(/[.,;!?]+$/, '');
2515        const safeUrl = safeEncodeUrl(cleanUrl);
2516        // The bracket body is the only link text that can carry angle
2517        // brackets (the standalone matcher excludes them) — keep it text.
2518        const safeText = cleanUrl.replace(/</g, '&lt;').replace(/>/g, '&gt;');
2519        // Return as a proper link without the brackets
2520        return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
2521    });
2522    
2523    // Process markdown links: [text](url) and [](url)
2524    // Uses balanced parenthesis matching to handle URLs containing parens
2525    // (e.g. PDF filenames with dates like (2025-08-28).pdf)
2526    processedText = (function(input) {
2527        var result = '';
2528        var i = 0;
2529        while (i < input.length) {
2530            // Look for [ at current position
2531            if (input[i] === '[') {
2532                // Find closing ]
2533                var closeBracket = input.indexOf(']', i + 1);
2534                if (closeBracket === -1 || closeBracket + 1 >= input.length || input[closeBracket + 1] !== '(') {
2535                    result += input[i];
2536                    i++;
2537                    continue;
2538                }
2539                var linkText = input.substring(i + 1, closeBracket);
2540                // Check if URL starts with http
2541                var urlStart = closeBracket + 2;
2542                if (!input.substring(urlStart).match(/^https?:\/\//)) {
2543                    result += input[i];
2544                    i++;
2545                    continue;
2546                }
2547                // Find balanced closing paren
2548                var depth = 1;
2549                var j = urlStart;
2550                while (j < input.length && depth > 0) {
2551                    if (input[j] === '(') depth++;
2552                    else if (input[j] === ')') depth--;
2553                    if (depth > 0) j++;
2554                }
2555                if (depth !== 0) {
2556                    result += input[i];
2557                    i++;
2558                    continue;
2559                }
2560                var url = input.substring(urlStart, j);
2561                var cleanUrl = url.replace(/[\].,;!?]+$/, '');
2562                var encodedUrl = safeEncodeUrl(cleanUrl);
2563                if (!linkText || !linkText.trim()) {
2564                    result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + cleanUrl + '</a>';
2565                } else {
2566                    var safeText = sanitizeUserInput(linkText);
2567                    result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + safeText + '</a>';
2568                }
2569                i = j + 1; // Skip past the closing )
2570            } else {
2571                result += input[i];
2572                i++;
2573            }
2574        }
2575        return result;
2576    })(processedText);
2577    
2578    // Process phone numbers: [text](tel:number)
2579    const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
2580    processedText = processedText.replace(phonePattern, (match, text, phone) => {
2581        const safePhone = safeEncodeUrl(phone);
2582        const safeText = sanitizeUserInput(text);
2583        return `<a href="${safePhone}">${safeText}</a>`;
2584    });
2585    
2586    // Process mailto links: [text](mailto:email)
2587    const mailtoPattern = /\[([^\]]+)\]\((mailto:[^\)]+)\)/g;
2588    processedText = processedText.replace(mailtoPattern, (match, text, mailto) => {
2589        const safeMailto = safeEncodeUrl(mailto);
2590        const safeText = sanitizeUserInput(text);
2591        return `<a href="${safeMailto}">${safeText}</a>`;
2592    });
2593    
2594    // Process standalone URLs - but NOT if they're already in <a> tags or brackets
2595    // Updated pattern to be more careful about what it matches
2596    const urlPattern = /(^|[^">=\[\]])(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2597    processedText = processedText.replace(urlPattern, (match, prefix, url) => {
2598        // Extra check: make sure this isn't already linked
2599        if (match.includes('href=') || match.includes('</a>')) {
2600            return match;
2601        }
2602        
2603        // Clean trailing punctuation
2604        let cleanUrl = url.replace(/[.,;!?)]+$/, '');
2605        const safeUrl = safeEncodeUrl(cleanUrl);
2606        return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2607    });
2608    
2609    // Process www. URLs - but NOT if they're already in <a> tags or brackets
2610    const wwwPattern = /(^|[^">/\[\]])(www\.[\S]+)(?![^<]*<\/a>)(?!\])/gim;
2611    processedText = processedText.replace(wwwPattern, (match, prefix, url) => {
2612        // Extra check: make sure this isn't already linked
2613        if (match.includes('href=') || match.includes('</a>')) {
2614            return match;
2615        }
2616        
2617        // Clean trailing punctuation
2618        let cleanUrl = url.replace(/[.,;!?)]+$/, '');
2619        const safeUrl = safeEncodeUrl(`http://${cleanUrl}`);
2620        return `${prefix}<a href="${safeUrl}" target="${linkTarget}">
2620${cleanUrl}</a>`;
2621    });
2622    
2623    return processedText;
2624}
2625     
2626    // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2627    // A single alternation keeps the two honest with each other: an escaped
2628    // backtick is consumed as an escape before it can open a code span, and a
2629    // code span is consumed whole so the escapes inside it stay verbatim.
2630    // Placeholders are private-use characters — no underscore, asterisk,
2631    // backtick, hash, pipe or bracket for a later rule to match.
2632    function protectLiterals(text) {
2633        const store = [];
2634        const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2635            store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2636            return '\uE000' + (store.length - 1) + '\uE001';
2637        });
2638        return {
2639            text: out,
2640            restore: function(t) {
2641                return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2642                    return store[Number(i)];
2643                });
2644            }
2645        };
2646    }
2647
2648    function formatMarkdownHeaders(text) {
2649        // Handle h1 to h6 headers
2650        return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2651            const level = hashes.length;
2652            return `<h${level} class="chat-heading chat-heading-${level}">${content.trim()}</h${level}>`;
2653        });
2654    }
2655    
2656function formatTextStyling(text) {
2657    // IMPORTANT: Protect BOTH HTML href and Markdown URLs from formatting
2658    const protectedSegments = [];
2659    let protectedText = text;
2660    
2661    // Step 1a: Protect HTML href="..." attributes
2662    protectedText = protectedText.replace(/href\s*=\s*["']([^"']+)["']/gi, function(match) {
2663        const placeholder = `__PROTECTED_${protectedSegments.length}__`;
2664        protectedSegments.push(match);
2665        return placeholder;
2666    });
2667    
2668    // Step 1b: Protect Markdown links [text](url)
2669    // This is crucial - we need to protect the URLs in markdown format
2670    protectedText = protectedText.replace(/\[([^\]]*)\]\(([^)]+)\)/g, function(match) {
2671        const placeholder = `__PROTECTED_${protectedSegments.length}__`;
2672        protectedSegments.push(match);
2673        return placeholder;
2674    });
2675    
2676    // Step 1c: Also protect bare URLs that might exist
2677    protectedText = protectedText.replace(/(https?:\/\/[^\s<>"]+)/gi, function(match) {
2678        const placeholder = `__PROTECTED_${protectedSegments.length}__`;
2679        protectedSegments.push(match);
2680        return placeholder;
2681    });
2682    
2683    // Step 2: Now apply text styling to the protected text
2684    // Handle bold text (**text**)
2685    protectedText = protectedText.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
2686
2687    // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2688    // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2689    protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2690
2691    // Handle underscores for italic - Safari-compatible (no lookbehind), and
2692    // word-bounded (plan 9dc34f): the opener must follow the start of the
2693    // text or a non-word character, the closer must precede the end or a
2694    // non-word character, and the content may not start or end with a
2695    // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2696    // while "use _this_ word" still italicises. A token whose content is
2697    // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2698    // convention, not prose emphasis — it is left verbatim too (ticket 440).
2699    // __PROTECTED_N__ placeholders cannot match: their underscores are all
2700    // adjacent to another underscore or a word character.
2701    protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2702        if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2703            return match;
2704        }
2705        return before + '<em>' + content + '</em>';
2706    });
2707    
2708    // Handle strikethrough (~~text~~)
2709    protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2710    
2711    // Step 3: Restore all protected segments
2712    protectedSegments.forEach((original, index) => {
2713        const placeholder = `__PROTECTED_${index}__`;
2714        protectedText = protectedText.replace(placeholder, original);
2715    });
2716    
2717    return protectedText;
2718}
2719    function formatBoldText(text) {
2720        // This function is kept for compatibility but now uses formatTextStyling
2721        return formatTextStyling(text);
2722    }
2723    
2724function convertNewlinesToBreaks(text) {
2725    // Split the text into paragraphs (marked by double newlines or multiple <br> tags)
2726    const paragraphs = text.split(/(?:\n\n|\<br\>\s*\<br\>)/g);
2727    
2728    // Filter out empty paragraphs and wrap each paragraph in <p> tags
2729    return paragraphs
2730        .map(para => para.trim())
2731        .filter(para => para.length > 0) // Remove empty paragraphs
2732        .map(para => `<p>${para}</p>`)
2733        .join('');
2734}
2735    function formatCodeBlocks(text) {
2736        // Handle fenced code blocks with language specification (```language)
2737        text = text.replace(/```(\w+)?\n?([\s\S]*?)```/g, (match, language, code) => {
2738            const lang = language || 'text';
2739            const escapedCode = escapeHtml(code.trim());
2740            return `<div class="mxchat-code-block-container">
2741                <div class="mxchat-code-header">
2742                    <span class="mxchat-code-language">${lang}</span>
2743                    <button class="mxchat-copy-button" aria-label="Copy to clipboard">Copy</button>
2744                </div>
2745                <pre class="mxchat-code-block"><code class="language-${lang}">${escapedCode}</code></pre>
2746            </div>`;
2747        });
2748
2749        // Handle inline code with single backticks
2750        text = text.replace(/`([^`\n]+)`/g, '<code class="mxchat-inline-code">$1</code>');
2751
2752        // Handle raw PHP tags (legacy support)
2753        text = text.replace(/(<\?php[\s\S]*?\?>)/g, (match) => {
2754            const escapedCode = escapeHtml(match);
2755            return `<div class="mxchat-code-block-container">
2756                <div class="mxchat-code-header">
2757                    <span class="mxchat-code-language">php</span>
2758                    <button class="mxchat-copy-button" aria-label="Copy to clipboard">Copy</button>
2759                </div>
2760                <pre class="mxchat-code-block"><code class="language-php">${escapedCode}</code></pre>
2761            </div>`;
2762        });
2763
2764        return text;
2765    }
2766
2767    function formatMarkdownTables(text) {
2768        var lines = text.split('\n');
2769        var result = [];
2770        var i = 0;
2771
2772        while (i < lines.length) {
2773            // Check for a table: current line has pipes AND next line is a separator row
2774            if (i + 1 < lines.length &&
2775                lines[i].indexOf('|') !== -1 &&
2776                /^\s*\|?[\s\-:]+(\|[\s\-:]+)+\|?\s*$/.test(lines[i + 1])) {
2777
2778                var tableLines = [];
2779                var headerLine = lines[i];
2780                var separatorLine = lines[i + 1];
2781                tableLines.push(headerLine);
2782                tableLines.push(separatorLine);
2783
2784                // Collect remaining table rows
2785                var j = i + 2;
2786                while (j < lines.length && lines[j].indexOf('|') !== -1 && lines[j].trim() !== '') {
2787                    tableLines.push(lines[j]);
2788                    j++;
2789                }
2790
2791                // Parse alignment from separator row
2792                var sepCells = separatorLine.split('|').filter(function(c) { return c.trim() !== ''; });
2793                var alignments = sepCells.map(function(cell) {
2794                    var trimmed = cell.trim();
2795                    if (trimmed.charAt(0) === ':' && trimmed.charAt(trimmed.length - 1) === ':') return 'center';
2796                    if (trimmed.charAt(trimmed.length - 1) === ':') return 'right';
2797                    return 'left';
2798                });
2799
2800                // Build HTML table
2801                var html = '<div class="mxchat-table-wrapper"><table class="mxchat-table">';
2802
2803                // Header row
2804                var headerCells = tableLines[0].split('|').filter(function(c) { return c.trim() !== ''; });
2805                html += '<thead><tr>';
2806                headerCells.forEach(function(cell, idx) {
2807                    var align = alignments[idx] || 'left';
2808                    html += '<th style="text-align:' + align + '">' + cell.trim() + '</th>';
2809                });
2810                html += '</tr></thead>';
2811
2812                // Body rows
2813                html += '<tbody>';
2814                for (var r = 2; r < tableLines.length; r++) {
2815                    var rowCells = tableLines[r].split('|').filter(function(c) { return c.trim() !== ''; });
2816                    html += '<tr>';
2817                    rowCells.forEach(function(cell, idx) {
2818                        var align = alignments[idx] || 'left';
2819                        html += '<td style="text-align:' + align + '">' + cell.trim() + '</td>';
2820                    });
2821                    html += '</tr>';
2822                }
2823                html += '</tbody></table></div>';
2824
2825                result.push(html);
2826                i = j;
2827            } else {
2828                result.push(lines[i]);
2829                i++;
2830            }
2831        }
2832
2833        return result.join('\n');
2834    }
2835
2836    function sanitizeUserInput(text) {
2837        const div = document.createElement('div');
2838        div.textContent = text;
2839        return div.innerHTML;
2840    }
2841
2842    // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2843    // Percent-encodes the characters that can end or extend an attribute.
2844    // Valid in every scheme the widget emits (http, https, tel, mailto) and
2845    // never changes where a legitimate link resolves — encodeURI() already
2846    // produces most of these, this just guarantees them for URLs that skip
2847    // encodeURI() because they were percent-encoded on arrival.
2848    function escapeAttr(url) {
2849        return String(url)
2850            .replace(/"/g, '%22')
2851            .replace(/'/g, '%27')
2852            .replace(/</g, '%3C')
2853            .replace(/>/g, '%3E')
2854            .replace(/`/g, '%60')
2855            .replace(/ /g, '%20');
2856    }
2857
2858    function escapeHtml(unsafe) {
2859        // Skip escaping if it's already escaped or contains HTML code block markup
2860        if (unsafe.includes('&lt;') || unsafe.includes('&gt;') || 
2861            unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
2862            return unsafe;
2863        }
2864        
2865        return unsafe
2866            .replace(/&/g, "&amp;")
2867            .replace(/</g, "&lt;")
2868            .replace(/>/g, "&gt;")
2869            .replace(/"/g, "&quot;")
2870            .replace(/'/g, "&#039;");
2871    }
2872    
2873    function decodeHTMLEntities(text) {
2874        var textArea = document.createElement('textarea');
2875        textArea.innerHTML = text;
2876        return textArea.value;
2877    }
2878
2879    // ====================================
2880    // UI & SCROLLING CONTROLS
2881    // ====================================
2882    
2883    function scrollToBottom(botIdOrInstant, instant) {
2884        // Handle backward compatibility: scrollToBottom() or scrollToBottom(true/false)
2885        var botId = 'default';
2886        if (typeof botIdOrInstant === 'string') {
2887            botId = botIdOrInstant;
2888            instant = instant || false;
2889        } else if (typeof botIdOrInstant === 'boolean') {
2890            instant = botIdOrInstant;
2891        } else {
2892            instant = false;
2893        }
2894
2895        var chatBox = getElement(botId, 'chat-box');
2896        if (instant) {
2897            // Instantly set the scroll position to the bottom
2898            chatBox.scrollTop(chatBox.prop("scrollHeight"));
2899        } else {
2900            // Use requestAnimationFrame for smoother scrolling if needed
2901            let start = null;
2902            const scrollHeight = chatBox.prop("scrollHeight");
2903            const initialScroll = chatBox.scrollTop();
2904            const distance = scrollHeight - initialScroll;
2905            const duration = 500; // Duration in ms
2906
2907            function smoothScroll(timestamp) {
2908                if (!start) start = timestamp;
2909                const progress = timestamp - start;
2910                const currentScroll = initialScroll + (distance * (progress / duration));
2911                chatBox.scrollTop(currentScroll);
2912
2913                if (progress < duration) {
2914                    requestAnimationFrame(smoothScroll);
2915                } else {
2916                    chatBox.scrollTop(scrollHeight); // Ensure it's exactly at the bottom
2917                }
2918            }
2919
2920            requestAnimationFrame(smoothScroll);
2921        }
2922    }
2923
2924    function scrollElementToTop(element, botId, topOffset) {
2925        botId = botId || 'default';
2926        topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2927        var chatBox = getElement(botId, 'chat-box');
2928        var elementTop = element.position().top + chatBox.scrollTop();
2929        chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
2930    }
2931
2932    function showChatWidget(botId) {
2933        botId = botId || 'default';
2934        var $button = getElement(botId, 'floating-chatbot-button');
2935        // First ensure display is set
2936        $button.css('display', 'flex');
2937        // Then handle the fade
2938        $button.fadeTo(500, 1);
2939        // Force visibility
2940        $button.removeClass('hidden');
2941    }
2942
2943    function hideChatWidget(botId) {
2944        botId = botId || 'default';
2945        var $button = getElement(botId, 'floating-chatbot-button');
2946        $button.css('display', 'none');
2947        $button.addClass('hidden');
2948    }
2949    
2950    function disableScroll() {
2951        if (isMobile()) {
2952            $('body').css('overflow', 'hidden');
2953        }
2954    }
2955    
2956    function enableScroll() {
2957        if (isMobile()) {
2958            $('body').css('overflow', '');
2959        }
2960    }
2961    
2962    function isMobile() {
2963        // This can be a simple check, or more sophisticated detection of mobile devices
2964        return window.innerWidth <= 768; // Example threshold for mobile devices
2965    }
2966    
2967    function setFullHeight() {
2968        var vh = $(window).innerHeight() * 0.01;
2969        $(':root').css('--vh', vh + 'px');
2970    }
2971
2972
2973    // ====================================
2974    // NOTIFICATION SYSTEM
2975    // ====================================
2976    
2977    function createNotificationBadge() {
2978        const chatButton = document.getElementById('floating-chatbot-button');
2979
2980        if (!chatButton) return;
2981
2982        // Remove any existing badge first
2983        const existingBadge = chatButton.querySelector('.chat-notification-badge');
2984        if (existingBadge) {
2985            existingBadge.remove();
2986        }
2987    
2988        notificationBadge = document.createElement('div');
2989        notificationBadge.className = 'chat-notification-badge';
2990        notificationBadge.style.cssText = `
2991            display: none;
2992            position: absolute;
2993            top: -5px;
2994            right: -5px;
2995            background-color: red;
2996            color: white;
2997            border-radius: 50%;
2998            padding: 4px 8px;
2999            font-size: 12px;
3000            font-weight: bold;
3001            z-index: 10001;
3002        `;
3003        chatButton.style.position = 'relative';
3004        chatButton.appendChild(notificationBadge);
3005        
3006    }
3007    
3008    function showNotification(botId) {
3009        botId = botId || 'default';
3010        const badge = getElementDOM(botId, 'chat-notification-badge');
3011        var $floatingChatbot = getElement(botId, 'floating-chatbot');
3012        if (badge && $floatingChatbot.hasClass('hidden')) {
3013            badge.style.display = 'block';
3014            badge.textContent = '1';
3015        }
3016    }
3017
3018    function hideNotification(botId) {
3019        botId = botId || 'default';
3020        const badge = getElementDOM(botId, 'chat-notification-badge');
3021        if (badge) {
3022            badge.style.display = 'none';
3023        }
3024    }
3025
3026    function startNotificationChecking(botId) {
3027        botId = botId || 'default';
3028        const chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3029        if (!chatPersistenceEnabled) return;
3030
3031        createNotificationBadge(botId);
3032        var instance = MxChatInstances.get(botId);
3033        instance.notificationCheckInterval = setInterval(function() {
3034            checkForNewMessages(botId);
3035        }, 30000); // Check every 30 seconds
3036    }
3037
3038    function stopNotificationChecking(botId) {
3039        botId = botId || 'default';
3040        var instance = MxChatInstances.get(botId);
3041        if (instance.notificationCheckInterval) {
3042            clearInterval(instance.notificationCheckInterval);
3043        }
3044    }
3045    
3046    function checkForNewMessages() {
3047        const sessionId = getChatSession();
3048        const chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3049        
3050        if (!chatPersistenceEnabled) return;
3051    
3052        $.ajax({
3053            url: mxchatChat.ajax_url,
3054            type: 'POST',
3055            data: {
3056                action: 'mxchat_check_new_messages',
3057                session_id: sessionId,
3058                last_seen_id: lastSeenMessageId,
3059                nonce: mxchatChat.nonce
3060            },
3061            success: function(response) {
3062                if (response.success && response.data.hasNewMessages) {
3063                    showNotification();
3064                }
3065            }
3066        });
3067    }
3068
3069
3070// ====================================
3071// LIVE AGENT FUNCTIONALITY
3072// ====================================
3073
3074function startPolling(botId) {
3075    botId = botId || 'default';
3076    var instance = MxChatInstances.get(botId);
3077    // Clear any existing interval first
3078    stopPolling(botId);
3079    instance.pollingInterval = setInterval(function() {
3080        checkForAgentMessages(botId);
3081    }, 5000);
3082}
3083
3084function stopPolling(botId) {
3085    botId = botId || 'default';
3086    var instance = MxChatInstances.get(botId);
3087    if (instance.pollingInterval) {
3088        clearInterval(instance.pollingInterval);
3089        instance.pollingInterval = null;
3090    }
3091}
3092
3093function checkForAgentMessages(botId) {
3094    botId = botId || 'default';
3095    var instance = MxChatInstances.get(botId);
3096    const sessionId = getChatSession(botId);
3097    $.ajax({
3098        url: mxchatChat.ajax_url,
3099        type: 'POST',
3100        dataType: 'json',
3101        data: {
3102            action: 'mxchat_fetch_new_messages',
3103            session_id: sessionId,
3104            last_seen_id: instance.lastSeenMessageId,
3105            persistence_enabled: 'true',
3106            nonce: mxchatChat.nonce
3107        },
3108        success: function (response) {
3109            if (response.success && response.data?.new_messages) {
3110                let hasNewMessage = false;
3111
3112                response.data.new_messages.forEach(function (message) {
3113                    if (message.role === "agent" && !instance.processedMessageIds.has(message.id)) {
3114                        hasNewMessage = true;
3115                        appendMessage("agent", message.content, '', [], false, botId);
3116                        instance.lastSeenMessageId = message.id;
3117                        instance.processedMessageIds.add(message.id);
3118                    }
3119                });
3120
3121                if (hasNewMessage) {
3122                    enableChatInput(botId);
3123                }
3124
3125                var $floatingChatbot = getElement(botId, 'floating-chatbot');
3126                if (hasNewMessage && $floatingChatbot.hasClass('hidden')) {
3127                    showNotification(botId);
3128                }
3129
3130                scrollToBottom(botId, true);
3131            }
3132
3133            // Handle chat mode transitions (e.g. agent ended chat via !endchat)
3134            if (response.success && response.data?.chat_mode) {
3135                updateChatModeIndicator(response.data.chat_mode, botId);
3136            }
3137        },
3138        error: function (xhr, status, error) {
3139            // Polling error - silently continue
3140        }
3141    });
3142}
3143
3144    // ====================================
3145    // CHAT HISTORY & PERSISTENCE
3146    // ====================================
3147
3148function loadChatHistory(botId, onComplete) {
3149    botId = botId || 'default';
3150    var instance = MxChatInstances.get(botId);
3151
3152    // Prevent duplicate loading
3153    if (instance.chatHistoryLoaded) {
3154        if (onComplete) onComplete();
3155        return;
3156    }
3157
3158    // Use getChatSession which returns null if no session exists (does NOT create one)
3159    var sessionId = getChatSession(botId);
3160    var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3161
3162    // No session yet — nothing to load. History will load after first message via ensureSession.
3163    if (!sessionId) {
3164        instance.chatHistoryLoaded = true;
3165        if (onComplete) onComplete();
3166        return;
3167    }
3168
3169    if (chatPersistenceEnabled && sessionId) {
3170        $.ajax({
3171            url: mxchatChat.ajax_url,
3172            type: 'POST',
3173            dataType: 'json',
3174            data: {
3175                action: 'mxchat_fetch_conversation_history',
3176                session_id: sessionId
3177            },
3178            success: function(response) {
3179                // Handle session reset (IP changed while user was away)
3180                if (response.success === false && response.data && response.data.action === 'reset_session') {
3181                    // Silent reset — new session but don't clear UI
3182                    MxChatInstances.silentResetSession(botId);
3183                    instance.chatHistoryLoaded = true; // Prevent retry loop
3184                    if (onComplete) onComplete();
3185                    return;
3186                }
3187
3188                // Check if the response indicates success
3189                if (response.success) {
3190                    // Handle case where conversation data exists and is an array
3191                    if (response.data && Array.isArray(response.data.conversation)) {
3192                        var $chatBox = getElement(botId, 'chat-box');
3193                        var $fragment = $(document.createDocumentFragment());
3194                        let highestMessageId = instance.lastSeenMessageId;
3195
3196                        // Update chat mode if provided
3197                        if (response.data.chat_mode) {
3198                            updateChatModeIndicator(response.data.chat_mode, botId);
3199                        }
3200
3201                        // Only process if there are actual messages
3202                        if (response.data.conversation.length > 0) {
3203                            // Restored history must be SILENT to screen readers
3204                            // (plan 67f126): these are DOM additions inside the
3205                            // live region and would otherwise announce as if
3206                            // they just arrived. Lift aria-live for the batch
3207                            // repopulate, restore it after the browser has
3208                            // processed the mutations.
3209                            var mxLiveRegionEl = $chatBox.get(0);
3210                            var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3211                            if (mxLiveRegionEl) {
3212                                mxLiveRegionEl.setAttribute('aria-live', 'off');
3213                            }
3214
3215                            // IMPORTANT: Clear existing messages before loading history.
3216                            // Detach the greeting first and put it back below —
3217                            // it is server-rendered and never stored in the
3218                            // transcript, so the old unconditional .empty()
3219                            // deleted it for the rest of the page life (plan
3220                            // a1a79b). Detach rather than rebuild: intro_message
3221                            // is not localized to JS, and this node already
3222                            // carries the per-bot inline colors and any
3223                            // {visitor_name} substitution already applied to it.
3224                            var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
3225                            $chatBox.empty();
3226
3227                            $.each(response.data.conversation, function(index, message) {
3228                                // Skip agent messages if persistence is off
3229                                if (!chatPersistenceEnabled && message.role === 'agent') {
3230                                    return;
3231                                }
3232
3233                                var messageClass, messageBgColor, messageFontColor;
3234
3235                                switch (message.role) {
3236                                    case 'user':
3237                                        messageClass = 'user-message';
3238                                        messageBgColor = userMessageBgColor;
3239                                        messageFontColor = userMessageFontColor;
3240                                        break;
3241                                    case 'agent':
3242                                        messageClass = 'agent-message';
3243                                        messageBgColor = liveAgentMessageBgColor;
3244                                        messageFontColor = liveAgentMessageFontColor;
3245                                        break;
3246                                    default:
3247                                        messageClass = 'bot-message';
3248                                        messageBgColor = botMessageBgColor;
3249                                        messageFontColor = botMessageFontColor;
3250                                        break;
3251                                }
3252
3253                                var messageElement = $('<div>').addClass(messageClass)
3254                                    .css({
3255                                        'background': messageBgColor,
3256                                        'color': messageFontColor
3257                                    });
3258
3259                                var content = message.content;
3260                                content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3261                                content = decodeHTMLEntities(content);
3262
3263                                // A visitor's own rows get the same treatment as the
3264                                // live turn (appendMessage escapes user text before
3265                                // linkify). Without this a replayed user message came
3266                                // back as live markup after a refresh (plan 2db182).
3267                                // Bot/agent rows are trusted HTML and stay as they were.
3268                                if (message.role === 'user') {
3269                                    content = sanitizeUserInput(content);
3270                                }
3271
3272                                // Skip linkify for messages containing structured HTML
3273                                // (forms, product cards, galleries, etc.) to avoid
3274                                // markdown formatting corrupting HTML attributes
3275                                // (e.g. underscores in name="field_name" becoming <em> tags).
3276                                // One family check instead of a per-card literal list: any
3277                                // element carrying an mxchat- prefixed class is MxChat-generated
3278                                // structured markup and replays raw. The old list drifted every
3279                                // time an add-on minted a new card class — the filtered-search
3280                                // card ("mxchat-filtered-product-card") missed it and replayed
3281                                // through linkify as visible markup.
3282                                if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
3283                                    content.includes("<form") ||
3284                                    content.includes("<input") ||
3285                                    content.includes("<select") ||
3286                                    content.includes("<textarea")) {
3287                                    messageElement.html(content);
3288                                } else {
3289                                    var formattedContent = linkify(content);
3290                                    messageElement.html(formattedContent);
3291                                }
3292
3293                                $fragment.append(messageElement);
3294
3295                                // Track message IDs
3296                                if (message.id) {
3297                                    highestMessageId = Math.max(highestMessageId, message.id);
3298                                    instance.processedMessageIds.add(message.id);
3299                                }
3300                            });
3301
3302                            // Only append messages and scroll if we have content.
3303                            // Greeting goes back FIRST, above the restored
3304                            // history: "Hello — [earlier conversation]" is the
3305                            // natural reading and matches the order a fresh
3306                            // visitor sees (plan a1a79b).
3307                            if ($mxIntro && $mxIntro.length) {
3308                                $chatBox.append($mxIntro);
3309                            }
3310                            $chatBox.append($fragment);
3311                            scrollToBottom(botId, true);
3312
3313                            // Re-attach live semantics AFTER the rehydration
3314                            // mutations have been processed with the region off
3315                            // (plan 67f126). Restoring later announces nothing
3316                            // retroactively; new turns announce normally.
3317                            if (mxLiveRegionEl) {
3318                                setTimeout(function() {
3319                                    mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3320                                }, 200);
3321                            }
3322
3323                            // Collapse quick questions if we have conversation history
3324                            // BUT skip auto-collapse for embedded bots (they should stay expanded)
3325                            if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3326                                collapseQuickQuestions(botId);
3327                            }
3328
3329                            // Update lastSeenMessageId after history loads
3330                            instance.lastSeenMessageId = highestMessageId;
3331
3332                            // Only update chat mode if persistence is enabled and we have messages
3333                            if (chatPersistenceEnabled) {
3334                                var lastMessage = response.data.conversation[response.data.conversation.length - 1];
3335                                if (lastMessage.role === 'agent') {
3336                                    updateChatModeIndicator('agent', botId);
3337                                }
3338                            }
3339
3340                            // Mark as loaded ONLY after successful load
3341                            instance.chatHistoryLoaded = true;
3342                        }
3343                    }
3344                }
3345                if (onComplete) onComplete();
3346            },
3347            error: function(xhr, status, error) {
3348                // Error loading chat history - silently continue
3349                if (onComplete) onComplete();
3350            }
3351        });
3352    } else {
3353        if (onComplete) onComplete();
3354    }
3355}
3356
3357
3358    // ====================================
3359    // FILE UPLOAD FUNCTIONALITY
3360    // ====================================
3361    
3362    function addSafeEventListener(elementId, eventType, handler) {
3363        const element = document.getElementById(elementId);
3364        if (element) {
3365            element.addEventListener(eventType, handler);
3366        }
3367    }
3368    
3369    function showActivePdf(filename, botId) {
3370        botId = botId || 'default';
3371        const container = getElementDOM(botId, 'active-pdf-container');
3372        const nameElement = getElementDOM(botId, 'active-pdf-name');
3373
3374        if (!container || !nameElement) {
3375            return;
3376        }
3377
3378        nameElement.textContent = filename;
3379        container.style.display = 'flex';
3380    }
3381
3382    function showActiveWord(filename, botId) {
3383        botId = botId || 'default';
3384        const container = getElementDOM(botId, 'active-word-container');
3385        const nameElement = getElementDOM(botId, 'active-word-name');
3386
3387        if (!container || !nameElement) {
3388            return;
3389        }
3390
3391        nameElement.textContent = filename;
3392        container.style.display = 'flex';
3393    }
3394
3395    function removeActivePdf(botId) {
3396        botId = botId || 'default';
3397        var instance = MxChatInstances.get(botId);
3398        const container = getElementDOM(botId, 'active-pdf-container');
3399        const nameElement = getElementDOM(botId, 'active-pdf-name');
3400
3401        if (!container || !nameElement || !instance.activePdfFile) return;
3402
3403        fetch(mxchatChat.ajax_url, {
3404            method: 'POST',
3405            headers: {
3406                'Content-Type': 'application/x-www-form-urlencoded',
3407            },
3408            body: new URLSearchParams({
3409                'action': 'mxchat_remove_pdf',
3410                'session_id': getChatSession(botId),
3411                'nonce': mxchatChat.nonce
3412            })
3413        })
3414        .then(response => response.json())
3415        .then(data => {
3416            if (data.success) {
3417                container.style.display = 'none';
3418                nameElement.textContent = '';
3419                instance.activePdfFile = null;
3420                appendMessage('bot', 'PDF removed.', '', [], false, botId);
3421            }
3422        })
3423        .catch(error => {
3424            // Error removing PDF - silently continue
3425        });
3426    }
3427
3428    function removeActiveWord(botId) {
3429        botId = botId || 'default';
3430        var instance = MxChatInstances.get(botId);
3431        const container = getElementDOM(botId, 'active-word-container');
3432        const nameElement = getElementDOM(botId, 'active-word-name');
3433
3434        if (!container || !nameElement || !instance.activeWordFile) return;
3435
3436        fetch(mxchatChat.ajax_url, {
3437            method: 'POST',
3438            headers: {
3439                'Content-Type': 'application/x-www-form-urlencoded',
3440            },
3441            body: new URLSearchParams({
3442                'action': 'mxchat_remove_word',
3443                'session_id': getChatSession(botId),
3444                'nonce': mxchatChat.nonce
3445            })
3446        })
3447        .then(response => response.json())
3448        .then(data => {
3449            if (data.success) {
3450                container.style.display = 'none';
3451                nameElement.textContent = '';
3452                instance.activeWordFile = null;
3453                appendMessage('bot', 'Word document removed.', '', [], false, botId);
3454            }
3455        })
3456        .catch(error => {
3457            // Error removing Word document - silently continue
3458        });
3459    }
3460
3461    // ====================================
3462    // CONSENT & COMPLIANCE (GDPR)
3463    // ====================================
3464
3465    function initializeChatVisibility(botId) {
3466        botId = botId || 'default';
3467        const complianzEnabled = mxchatChat.complianz_toggle === 'on' ||
3468                                mxchatChat.complianz_toggle === '1' ||
3469                                mxchatChat.complianz_toggle === 1;
3470
3471        if (complianzEnabled && typeof cmplz_has_consent === "function" && typeof complianz !== 'undefined') {
3472            // Initial check
3473            checkConsentAndShowChat(botId);
3474
3475            // Listen for consent changes
3476            $(document).on('cmplz_status_change', function(event) {
3477                checkConsentAndShowChat(botId);
3478            });
3479        } else {
3480            // If Complianz is not enabled, always show
3481            getElement(botId, 'floating-chatbot-button')
3482                .css('display', 'flex')
3483                .removeClass('hidden no-consent')
3484                .fadeTo(500, 1);
3485
3486            // Also check pre-chat message when Complianz is not enabled
3487            checkPreChatDismissal(botId);
3488        }
3489    }
3490
3491
3492    function checkConsentAndShowChat(botId) {
3493        botId = botId || 'default';
3494        var consentStatus = cmplz_has_consent('marketing');
3495        var consentType = complianz.consenttype;
3496
3497        let $widget = getElement(botId, 'floating-chatbot-button');
3498        let $chatbot = getElement(botId, 'floating-chatbot');
3499        let $preChat = getElement(botId, 'pre-chat-message');
3500
3501        if (consentStatus === true) {
3502            $widget
3503                .removeClass('no-consent')
3504                .css('display', 'flex')
3505                .removeClass('hidden')
3506                .fadeTo(500, 1);
3507            $chatbot.removeClass('no-consent');
3508
3509            // Show pre-chat message if not dismissed
3510            checkPreChatDismissal(botId);
3511        } else {
3512            $widget
3513                .addClass('no-consent')
3514                .fadeTo(500, 0, function() {
3515                    $(this)
3516                        .css('display', 'none')
3517                        .addClass('hidden');
3518                });
3519            $chatbot.addClass('no-consent');
3520
3521            // Hide pre-chat message when no consent
3522            $preChat.hide();
3523        }
3524    }
3525
3526
3527    // ====================================
3528    // PRE-CHAT MESSAGE HANDLING
3529    // ====================================
3530
3531    function checkPreChatDismissal(botId) {
3532        botId = botId || 'default';
3533        try {
3534            var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
3535            if (dismissedAt) {
3536                // Re-show after 24 hours
3537                var elapsed = Date.now() - parseInt(dismissedAt, 10);
3538                if (elapsed < 86400000) {
3539                    getElement(botId, 'pre-chat-message').hide();
3540                    return;
3541                }
3542                // Expired — clear and show again
3543                localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
3544            }
3545            getElement(botId, 'pre-chat-message').fadeIn(250);
3546        } catch (e) {
3547            // localStorage unavailable — show the message
3548            getElement(botId, 'pre-chat-message').fadeIn(250);
3549        }
3550    }
3551
3552    function handlePreChatDismissal(botId) {
3553        botId = botId || 'default';
3554        getElement(botId, 'pre-chat-message').fadeOut(200);
3555        try {
3556            localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now()));
3557        } catch (e) {
3558            // localStorage unavailable — dismissal won't persist
3559        }
3560    }
3561
3562
3563    // ====================================
3564    // UTILITY FUNCTIONS
3565    // ====================================
3566    
3567    function copyToClipboard(text) {
3568        var tempInput = $('<input>');
3569        $('body').append(tempInput);
3570        tempInput.val(text).select();
3571        document.execCommand('copy');
3572        tempInput.remove();
3573    }
3574
3575    
3576    function isImageHtml(str) {
3577        return str.startsWith('<img') && str.endsWith('>');
3578    }
3579
3580
3581    // ====================================
3582    // EVENT HANDLERS & INITIALIZATION
3583    // ====================================
3584
3585$(document).on('click', '.mxchat-popular-question', function () {
3586    var question = $(this).text();
3587    var botId = getBotIdFromElement(this);
3588
3589    // Append the question as if the user typed it
3590    appendMessage("user", question, '', [], false, botId);
3591
3592    // Only collapse if there are questions
3593    if (hasQuickQuestions(botId)) {
3594        collapseQuickQuestions(botId);
3595    }
3596
3597    // Send the question to the server
3598    sendMessageToChatbot(question, botId);
3599});
3600
3601$(document).on('click', '.questions-toggle-btn', function(e) {
3602    e.preventDefault();
3603    e.stopPropagation();
3604    var botId = getBotIdFromElement(this);
3605    expandQuickQuestions(botId);
3606});
3607
3608$(document).on('click', '.questions-collapse-btn', function(e) {
3609    e.preventDefault();
3610    e.stopPropagation();
3611    var botId = getBotIdFromElement(this);
3612    collapseQuickQuestions(botId);
3613});
3614
3615// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3616// thumbnail facade — no Google iframe exists until the visitor taps play.
3617// Delegated so it also works for embeds restored from chat history.
3618$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3619    e.preventDefault();
3620    var $wrap = $(this).closest('.mxchat-youtube-embed');
3621    var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3622    if (!videoId) {
3623        return;
3624    }
3625    var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3626    var $iframe = $('<iframe>', {
3627        src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3628        title: title,
3629        allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3630        allowfullscreen: true,
3631        frameborder: 0
3632    }).addClass('mxchat-youtube-iframe');
3633    $wrap.addClass('mxchat-youtube-playing');
3634    $(this).replaceWith($iframe);
3635});
3636    
3637    // Chatbot visibility toggle handlers - use class selector for multi-instance support
3638    // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3639    $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3640        if (e.type === 'keydown') {
3641            if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3642            e.preventDefault();
3643        }
3644        var botId = getBotIdFromElement(this);
3645        var $chatbot = getElement(botId, 'floating-chatbot');
3646        var $badge = getElement(botId, 'chat-notification-badge');
3647        var $preChat = getElement(botId, 'pre-chat-message');
3648
3649        if ($chatbot.hasClass('hidden')) {
3650            $chatbot.removeClass('hidden').addClass('visible')
3651                    .attr('aria-modal', 'true').attr('role', 'dialog');
3652            $(this).addClass('hidden').attr('aria-expanded', 'true');
3653            $badge.hide(); // Hide notification when opening chat
3654            disableScroll();
3655            $preChat.fadeOut(250);
3656            mxchatFireOpened(botId);
3657
3658            // First open per page load: re-fetch behavior settings in case
3659            // this page's inline values came from a stale full-page cache
3660            // (plan-32db95). Idempotent — later opens are a no-op.
3661            mxchatRefreshDynamicSettings();
3662
3663            // Load chat history for returning visitors (persistence)
3664            var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3665            if (chatPersistenceEnabled) {
3666                MxChatInstances.ensureSession(botId);
3667            }
3668
3669            // Deferred email check — only on first widget open
3670            var emailBlocker = getElementDOM(botId, 'email-blocker');
3671            var instance = MxChatInstances.get(botId);
3672            if (emailBlocker && !instance.emailCheckDone) {
3673                instance.emailCheckDone = true;
3674                resolveEmailState(botId);
3675            } else if (!emailBlocker) {
3676                // No email collection — still route through showChatContainerForBot
3677                // so the loader is shown while chat history loads
3678                showChatContainerForBot(botId);
3679            }
3680
3681            // Move keyboard focus into the message input after the open transition.
3682            setTimeout(function() {
3683                var chatInput = getElementDOM(botId, 'chat-input');
3684                if (chatInput && !chatInput.disabled) {
3685                    try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3686                }
3687            }, 300);
3688        } else {
3689            $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3690            $(this).removeClass('hidden').attr('aria-expanded', 'false');
3691            enableScroll();
3692            checkPreChatDismissal(botId);
3693        }
3694    });
3695
3696    // Allow clicking anywhere on the title bar to close the chatbot.
3697    // Returns keyboard focus to the launcher so keyboard users don't get
3698    // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3699    // heuristic-based so mouse-triggered close won't show a focus ring.
3700    $(document).on('click', '.chatbot-top-bar', function() {
3701        var botId = getBotIdFromElement(this);
3702        getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3703        var $launcher = getElement(botId, 'floating-chatbot-button');
3704        $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3705        enableScroll();
3706        try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3707    });
3708
3709    // Global Escape-key handler — closes any visible chat widget and
3710    // returns focus to its launcher. Standard modal-dismissal pattern;
3711    // pairs with aria-modal="true" set on the widget when it opens.
3712    $(document).on('keydown', function(e) {
3713        if (e.key !== 'Escape' && e.key !== 'Esc') return;
3714        var $visible = $('.floating-chatbot.visible');
3715        if (!$visible.length) return;
3716        e.preventDefault();
3717        $visible.each(function() {
3718            var botId = getBotIdFromElement(this);
3719            $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3720            var $launcher = getElement(botId, 'floating-chatbot-button');
3721            $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3722            try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3723        });
3724        enableScroll();
3725    });
3726
3727    $(document).on('click', '.close-pre-chat-message', function(e) {
3728        e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3729        var botId = getBotIdFromElement(this);
3730        handlePreChatDismissal(botId);
3731    });
3732
3733
3734    // PDF upload button handlers - use class selector
3735    $(document).on('click', '.pdf-upload-btn', function() {
3736        var botId = getBotIdFromElement(this);
3737        var pdfInput = getElementDOM(botId, 'pdf-upload');
3738        if (pdfInput) pdfInput.click();
3739    });
3740
3741    // Word upload button handlers - use class selector
3742    $(document).on('click', '.word-upload-btn', function() {
3743        var botId = getBotIdFromElement(this);
3744        var wordInput = getElementDOM(botId, 'word-upload');
3745        if (wordInput) wordInput.click();
3746    });
3747    
3748    // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3749    $(document).on('change', '.pdf-upload', async function(e) {
3750        var botId = getBotIdFromElement(this);
3751        var instance = MxChatInstances.get(botId);
3752        const file = this.files[0];
3753        const sessionId = MxChatInstances.ensureSession(botId);
3754
3755        if (!file || file.type !== 'application/pdf') {
3756            alert('Please select a valid PDF file.');
3757            return;
3758        }
3759
3760        if (!sessionId) {
3761            alert('Error: No session ID found');
3762            return;
3763        }
3764
3765        if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3766            alert('Error: Ajax configuration missing');
3767            return;
3768        }
3769
3770        // Disable buttons and show loading state
3771        const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3772        const sendBtn = getElementDOM(botId, 'send-button');
3773        if (!uploadBtn) return;
3774        const originalBtnContent = uploadBtn.innerHTML;
3775
3776        try {
3777            // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3778            await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3779            const formData = new FormData();
3780            formData.append('action', 'mxchat_upload_pdf');
3781            formData.append('pdf_file', file);
3782            formData.append('session_id', sessionId);
3783            formData.append('nonce', mxchatChat.nonce);
3784
3785            uploadBtn.disabled = true;
3786            if (sendBtn) sendBtn.disabled = true;
3787            uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3788                <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3789            </svg>`;
3790
3791            const response = await fetch(mxchatChat.ajax_url, {
3792                method: 'POST',
3793                body: formData
3794            });
3795
3796            const data = await response.json();
3797
3798            if (data.success) {
3799                // Hide popular questions if they exist
3800                if (hasQuickQuestions(botId)) {
3801                    collapseQuickQuestions(botId);
3802                }
3803
3804                // Show the active PDF name
3805                showActivePdf(data.data.filename, botId);
3806
3807                appendMessage('bot', data.data.message, '', [], false, botId);
3808                scrollToBottom(botId);
3809                instance.activePdfFile = data.data.filename;
3810            } else {
3811                alert('Failed to upload PDF. Please try again.');
3812            }
3813        } catch (error) {
3814            alert('Error uploading file. Please try again.');
3815        } finally {
3816            uploadBtn.disabled = false;
3817            if (sendBtn) sendBtn.disabled = false;
3818            uploadBtn.innerHTML = originalBtnContent;
3819            this.value = ''; // Reset file input
3820        }
3821    });
3822    
3823    // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3824    $(document).on('change', '.word-upload', async function(e) {
3825        var botId = getBotIdFromElement(this);
3826        var instance = MxChatInstances.get(botId);
3827        const file = this.files[0];
3828        const sessionId = MxChatInstances.ensureSession(botId);
3829
3830        if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3831            alert('Please select a valid Word document (.docx).');
3832            return;
3833        }
3834
3835        if (!sessionId) {
3836            alert('Error: No session ID found');
3837            return;
3838        }
3839
3840        if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3841            alert('Error: Ajax configuration missing');
3842            return;
3843        }
3844
3845        // Disable buttons and show loading state
3846        const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3847        const sendBtn = getElementDOM(botId, 'send-button');
3848        if (!uploadBtn) return;
3849        const originalBtnContent = uploadBtn.innerHTML;
3850
3851        try {
3852            // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3853            await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3854            const formData = new FormData();
3855            formData.append('action', 'mxchat_upload_word');
3856            formData.append('word_file', file);
3857            formData.append('session_id', sessionId);
3858            formData.append('nonce', mxchatChat.nonce);
3859
3860            uploadBtn.disabled = true;
3861            if (sendBtn) sendBtn.disabled = true;
3862            uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3863                <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3864            </svg>`;
3865
3866            const response = await fetch(mxchatChat.ajax_url, {
3867                method: 'POST',
3868                body: formData
3869            });
3870
3871            const data = await response.json();
3872
3873            if (data.success) {
3874                // Hide popular questions if they exist
3875                if (hasQuickQuestions(botId)) {
3876                    collapseQuickQuestions(botId);
3877                }
3878
3879                // Show the active Word document name
3880                showActiveWord(data.data.filename, botId);
3881
3882                appendMessage('bot', data.data.message, '', [], false, botId);
3883                scrollToBottom(botId);
3884                instance.activeWordFile = data.data.filename;
3885            } else {
3886                alert('Failed to upload Word document. Please try again.');
3887            }
3888        } catch (error) {
3889            alert('Error uploading file. Please try again.');
3890        } finally {
3891            uploadBtn.disabled = false;
3892            if (sendBtn) sendBtn.disabled = false;
3893            uploadBtn.innerHTML = originalBtnContent;
3894            this.value = ''; // Reset file input
3895        }
3896    });
3897    
3898    // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3899    $(document).on('click', '.remove-pdf-btn', function(e) {
3900        e.preventDefault();
3901        e.stopPropagation();
3902        removeActivePdf(getBotIdFromElement(this));
3903    });
3904
3905    $(document).on('click', '.remove-word-btn', function(e) {
3906        e.preventDefault();
3907        e.stopPropagation();
3908        removeActiveWord(getBotIdFromElement(this));
3909    });
3910    
3911    // Window resize handlers
3912    $(window).on('resize orientationchange', function() {
3913        setFullHeight();
3914    });
3915
3916
3917    // ====================================
3918    // TOOLBAR & STYLING SETUP
3919    // ====================================
3920    
3921    // Apply toolbar settings
3922    if (mxchatChat.chat_toolbar_toggle === 'on') {
3923        $('.chat-toolbar').show();
3924    } else {
3925        $('.chat-toolbar').hide();
3926    }
3927    // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3928    MxChatInstances.toolbarReady = true;
3929    mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3930
3931    // Apply toolbar icon colors
3932    const toolbarElements = [
3933        '#mxchat-chatbot .toolbar-btn svg',
3934        '#mxchat-chatbot .active-pdf-name',
3935        '#mxchat-chatbot .active-word-name',
3936        '#mxchat-chatbot .remove-pdf-btn svg',
3937        '#mxchat-chatbot .remove-word-btn svg',
3938        '#mxchat-chatbot .toolbar-perplexity svg'
3939    ];
3940    
3941    toolbarElements.forEach(selector => {
3942        $(selector).css({
3943            'fill': toolbarIconColor,
3944            'stroke': toolbarIconColor,
3945            'color': toolbarIconColor
3946        });
3947    });
3948
3949
3950// ====================================
3951//  INIT LOADER & CHAT CONTAINER HELPERS
3952// ====================================
3953// These must be outside the email collection block so they're always available
3954// (used by persistence loading even when email collection is off)
3955
3956function showInitLoader(botId) {
3957    var loader = getElementDOM(botId, 'mxchat-init-loader');
3958    if (loader) loader.style.display = 'flex';
3959}
3960
3961function hideInitLoader(botId) {
3962    var loader = getElementDOM(botId, 'mxchat-init-loader');
3963    if (loader) loader.style.display = 'none';
3964}
3965
3966function showEmailFormForBot(botId) {
3967    hideInitLoader(botId);
3968    var emailBlocker = getElementDOM(botId, 'email-blocker');
3969    var chatContainer = getElementDOM(botId, 'chat-container');
3970    if (emailBlocker) emailBlocker.style.display = 'flex';
3971    if (chatContainer) chatContainer.style.display = 'none';
3972}
3973
3974function showChatContainerForBot(botId) {
3975    var emailBlocker = getElementDOM(botId, 'email-blocker');
3976    var chatContainer = getElementDOM(botId, 'chat-container');
3977    if (emailBlocker) emailBlocker.style.display = 'none';
3978
3979    var instance = MxChatInstances.get(botId);
3980    var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3981
3982    // If persistence is on and history hasn't loaded yet, show loader
3983    // while history loads to prevent flash of empty chat
3984    if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
3985        if (chatContainer) chatContainer.style.display = 'none';
3986        showInitLoader(botId);
3987        loadChatHistory(botId, function() {
3988            hideInitLoader(botId);
3989            if (chatContainer) chatContainer.style.display = 'flex';
3990            scrollToBottom(botId, true);
3991        });
3992    } else {
3993        hideInitLoader(botId);
3994        if (chatContainer) chatContainer.style.display = 'flex';
3995        if (typeof loadChatHistory === 'function') {
3996            loadChatHistory(botId);
3997        }
3998    }
3999}
4000
4001// ====================================
4002//  EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
4003// ====================================
4004// Only run email collection setup if it's enabled
4005if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
4006
4007    // Track submitting state per bot
4008    const emailSubmittingState = {};
4009
4010    // Add CSS animations for email form (once globally)
4011    if (!document.getElementById('email-error-styles')) {
4012        const style = document.createElement('style');
4013        style.id = 'email-error-styles';
4014        style.textContent = `
4015            @keyframes fadeInError {
4016                from { opacity: 0; transform: translateY(-5px); }
4017                to { opacity: 1; transform: translateY(0); }
4018            }
4019            .email-input-shake {
4020                animation: shake 0.5s ease-in-out;
4021            }
4022            @keyframes shake {
4023                0%, 100% { transform: translateX(0); }
4024                25% { transform: translateX(-5px); }
4025                75% { transform: translateX(5px); }
4026            }
4027            @keyframes spin {
4028                from { transform: rotate(0deg); }
4029                to { transform: rotate(360deg); }
4030            }
4031            .email-spinner {
4032                display: inline-block;
4033                vertical-align: middle;
4034            }
4035        `;
4036        document.head.appendChild(style);
4037    }
4038
4039    function isValidEmailAddress(email) {
4040        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
4041        return emailRegex.test(email.trim()) && email.length <= 254;
4042    }
4043
4044    function isValidNameInput(name) {
4045        return name && name.trim().length >= 2 && name.trim().length <= 100;
4046    }
4047
4048    /**
4049     * Replace {visitor_name} placeholder in intro message with actual visitor name
4050     * @param {string} botId - The bot instance ID
4051     * @param {string} visitorName - The visitor's name to insert
4052     */
4053    function replaceVisitorNamePlaceholder(botId, visitorName) {
4054        var chatBox = getElementDOM(botId, 'chat-box');
4055        if (!chatBox) return;
4056
4057        // Find the greeting by its marker, not by position (plan a1a79b) —
4058        // after a persistence restore the first .bot-message is a restored
4059        // reply, and {visitor_name} was being substituted into that instead.
4060        // Positional fallback for HTML cached before this release only.
4061        var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
4062        if (!introMessage) return;
4063
4064        var messageContent = introMessage.querySelector('div[dir="auto"]');
4065        if (!messageContent) return;
4066
4067        var html = messageContent.innerHTML;
4068
4069        // Replace {visitor_name} placeholder (case-insensitive)
4070        if (visitorName && visitorName.trim()) {
4071            // Escape HTML to prevent XSS
4072            var safeName = $('<div>').text(visitorName.trim()).html();
4073            html = html.replace(/\{visitor_name\}/gi, safeName);
4074        } else {
4075            // Remove placeholder and clean up spacing if no name provided
4076            html = html.replace(/\{visitor_name\}/gi, '');
4077            // Clean up any double spaces that might result
4078            html = html.replace(/\s{2,}/g, ' ').trim();
4079        }
4080
4081        messageContent.innerHTML = html;
4082    }
4083
4084    function setEmailSubmissionState(botId, loading) {
4085        var submitButton = getElementDOM(botId, 'email-submit-button');
4086        var emailInput = getElementDOM(botId, 'user-email');
4087        var nameInput = getElementDOM(botId, 'user-name');
4088
4089        if (loading) {
4090            emailSubmittingState[botId] = true;
4091            if (submitButton) submitButton.disabled = true;
4092            if (emailInput) emailInput.disabled = true;
4093            if (nameInput) nameInput.disabled = true;
4094
4095            if (submitButton && !submitButton.getAttribute('data-original-html')) {
4096                submitButton.setAttribute('data-original-html', submitButton.innerHTML);
4097                const originalText = submitButton.textContent;
4098                submitButton.innerHTML = `
4099                    <svg class="email-spinner" style="width: 16px; height: 16px; margin-right: 8px; animation: spin 1s linear infinite;" viewBox="0 0 24 24">
4100                        <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-dasharray="31.416" stroke-dashoffset="31.416">
4101                            <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;
41010 31.416" repeatCount="indefinite"/>
4102                            <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
4103                        </circle>
4104                    </svg>
4105                    ${originalText}
4106                `;
4107                submitButton.style.opacity = '0.8';
4108            }
4109        } else {
4110            emailSubmittingState[botId] = false;
4111            if (submitButton) submitButton.disabled = false;
4112            if (emailInput) emailInput.disabled = false;
4113            if (nameInput) nameInput.disabled = false;
4114
4115            if (submitButton) {
4116                const originalHtml = submitButton.getAttribute('data-original-html');
4117                if (originalHtml) {
4118                    submitButton.innerHTML = originalHtml;
4119                }
4120                submitButton.style.opacity = '1';
4121            }
4122        }
4123    }
4124
4125    function showEmailError(botId, message) {
4126        clearEmailError(botId);
4127
4128        var emailForm = getElementDOM(botId, 'email-collection-form');
4129        if (!emailForm) return;
4130
4131        const errorDiv = document.createElement('div');
4132        errorDiv.className = 'email-error';
4133        errorDiv.style.cssText = `
4134            color: #e74c3c;
4135            font-size: 12px;
4136            margin-top: 8px;
4137            padding: 4px 0;
4138            animation: fadeInError 0.3s ease;
4139        `;
4140        errorDiv.textContent = message;
4141        emailForm.appendChild(errorDiv);
4142
4143        // Add shake animation to inputs
4144        var emailInput = getElementDOM(botId, 'user-email');
4145        var nameInput = getElementDOM(botId, 'user-name');
4146
4147        if (emailInput) {
4148            emailInput.classList.add('email-input-shake');
4149            setTimeout(() => emailInput.classList.remove('email-input-shake'), 500);
4150        }
4151        if (nameInput) {
4152            nameInput.classList.add('email-input-shake');
4153            setTimeout(() => nameInput.classList.remove('email-input-shake'), 500);
4154        }
4155    }
4156
4157    function clearEmailError(botId) {
4158        var emailForm = getElementDOM(botId, 'email-collection-form');
4159        if (emailForm) {
4160            const existingErrors = emailForm.querySelectorAll('.email-error');
4161            existingErrors.forEach(error => error.remove());
4162        }
4163    }
4164
4165    // Resolve email state using server-side data when available, AJAX fallback otherwise
4166    function resolveEmailState(botId) {
4167        if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
4168            if (mxchatChat.initial_email_state.show_email_form) {
4169                showEmailFormForBot(botId);
4170            } else {
4171                showChatContainerForBot(botId);
4172            }
4173        } else {
4174            checkSessionAndEmailForBot(botId);
4175        }
4176    }
4177
4178    function checkSessionAndEmailForBot(botId) {
4179        const sessionId = MxChatInstances.ensureSession(botId);
4180
4181        // Hide both panels while we check — show loader instead
4182        var emailBlocker = getElementDOM(botId, 'email-blocker');
4183        var chatContainer = getElementDOM(botId, 'chat-container');
4184        if (emailBlocker) emailBlocker.style.display = 'none';
4185        if (chatContainer) chatContainer.style.display = 'none';
4186        showInitLoader(botId);
4187
4188        fetch(mxchatChat.ajax_url, {
4189            method: 'POST',
4190            headers: {
4191                'Content-Type': 'application/x-www-form-urlencoded',
4192            },
4193            body: new URLSearchParams({
4194                action: 'mxchat_check_email_provided',
4195                session_id: sessionId,
4196                nonce: mxchatChat.nonce,
4197            })
4198        })
4199        .then((response) => {
4200            if (!response.ok) {
4201                throw new Error(`HTTP error! status: ${response.status}`);
4202            }
4203            return response.json();
4204        })
4205        .then((data) => {
4206            if (data.success) {
4207                // A success response means the server has already decided the
4208                // required info is on file. With email optional (c0cfaf) that
4209                // info can be a name and no address, so keying on data.email
4210                // alone re-prompted a name-only visitor on every page load.
4211                if (data.data.logged_in || data.data.email || data.data.name) {
4212                    showChatContainerForBot(botId);
4213                } else {
4214                    showEmailFormForBot(botId);
4215                }
4216            } else {
4217                // Pre-fill for a signed-in visitor being shown the form
4218                // (c0cfaf). It arrives on this nocache'd response rather than
4219                // in the page HTML, which is cacheable. Never overwrite what
4220                // the visitor has already typed.
4221                if (data.data) {
4222                    prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4223                }
4224                showEmailFormForBot(botId);
4225            }
4226        })
4227        .catch((error) => {
4228            showEmailFormForBot(botId);
4229        });
4230    }
4231
4232    function prefillEmailForm(botId, name, email) {
4233        var nameInput = getElementDOM(botId, 'user-name');
4234        var emailInput = getElementDOM(botId, 'user-email');
4235        if (nameInput && name && !nameInput.value) {
4236            nameInput.value = name;
4237        }
4238        if (emailInput && email && !emailInput.value) {
4239            emailInput.value = email;
4240        }
4241    }
4242
4243    // Event delegation for email form submission
4244    $(document).on('submit', '.email-collection-form', function(e) {
4245        e.preventDefault();
4246        e.stopPropagation();
4247
4248        var botId = getBotIdFromElement(this);
4249
4250        // Prevent double submission
4251        if (emailSubmittingState[botId]) {
4252            return false;
4253        }
4254
4255        var emailInput = getElementDOM(botId, 'user-email');
4256        var nameInput = getElementDOM(botId, 'user-name');
4257        var consentInput = getElementDOM(botId, 'user-consent');
4258        var userEmail = emailInput ? emailInput.value.trim() : '';
4259        var userName = nameInput ? nameInput.value.trim() : '';
4260        var sessionId = MxChatInstances.ensureSession(botId);
4261
4262        // Validate email. The address is optional when the owner turned
4263        // "Require Email Address" off (c0cfaf) — but anything actually typed
4264        // still has to be a valid address. The server enforces both rules
4265        // independently.
4266        var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4267
4268        if (emailRequired && !userEmail) {
4269            showEmailError(botId, 'Please enter your email address.');
4270            return false;
4271        }
4272
4273        if (userEmail && !isValidEmailAddress(userEmail)) {
4274            showEmailError(botId, 'Please enter a valid email address.');
4275            return false;
4276        }
4277
4278        if (!emailRequired && !userEmail && !userName) {
4279            showEmailError(botId, 'Please enter your name or email address.');
4280            return false;
4281        }
4282
4283        // Validate name if field exists and has content
4284        if (nameInput && userName && !isValidNameInput(userName)) {
4285            showEmailError(botId, 'Please enter a valid name (2-100 characters).');
4286            return false;
4287        }
4288
4289        // Consent checkbox (b062c4): backstop behind the native required
4290        // attribute; the server enforces this independently either way.
4291        if (consentInput && consentInput.required && !consentInput.checked) {
4292            showEmailError(botId, 'Please tick the consent box to continue.');
4293            return false;
4294        }
4295
4296        clearEmailError(botId);
4297        setEmailSubmissionState(botId, true);
4298
4299        // Prepare form data
4300        const formData = new URLSearchParams({
4301            action: 'mxchat_handle_save_email_and_response',
4302            email: userEmail,
4303            session_id: sessionId,
4304            nonce: mxchatChat.nonce,
4305        });
4306
4307        if (userName) {
4308            formData.append('name', userName);
4309        }
4310
4311        // Ticked/unticked both travel when the checkbox is rendered, so an
4312        // optional-consent "no" is recorded as a decision, not an absence.
4313        if (consentInput) {
4314            formData.append('consent', consentInput.checked ? '1' : '0');
4315        }
4316
4317        fetch(mxchatChat.ajax_url, {
4318            method: 'POST',
4319            headers: {
4320                'Content-Type': 'application/x-www-form-urlencoded',
4321            },
4322            body: formData
4323        })
4324        .then((response) => {
4325            if (!response.ok) {
4326                throw new Error(`HTTP error! status: ${response.status}`);
4327            }
4328            return response.json();
4329        })
4330        .then((data) => {
4331            setEmailSubmissionState(botId, false);
4332
4333            if (data.success) {
4334                showChatContainerForBot(botId);
4335
4336                // Replace {visitor_name}
4336 placeholder in intro message with actual name
4337                if (userName) {
4338                    replaceVisitorNamePlaceholder(botId, userName);
4339                } else {
4340                    // Remove placeholder if no name provided
4341                    replaceVisitorNamePlaceholder(botId, '');
4342                }
4343
4344                if (data.message && typeof appendMessage === 'function') {
4345                    setTimeout(() => {
4346                        appendMessage('bot', data.message, '', [], false, botId);
4347                        if (typeof scrollToBottom === 'function') {
4348                            scrollToBottom(botId);
4349                        }
4350                    }, 100);
4351                }
4352            } else {
4353                showEmailError(botId, data.message || 'Failed to save email. Please try again.');
4354            }
4355        })
4356        .catch((error) => {
4357            setEmailSubmissionState(botId, false);
4358            showEmailError(botId, 'An error occurred. Please try again.');
4359        });
4360
4361        return false;
4362    });
4363
4364    // Real-time email validation using event delegation
4365    $(document).on('input', '.mxchat-email-input', function() {
4366        var botId = getBotIdFromElement(this);
4367        var $input = $(this);
4368
4369        // Clear previous timeout
4370        clearTimeout($input.data('validationTimeout'));
4371
4372        // Debounce validation
4373        var timeout = setTimeout(() => {
4374            var email = this.value.trim();
4375            clearEmailError(botId);
4376
4377            if (email && !isValidEmailAddress(email)) {
4378                showEmailError(botId, 'Please enter a valid email address.');
4379            }
4380        }, 500);
4381
4382        $input.data('validationTimeout', timeout);
4383    });
4384
4385    // Handle Enter key in email input
4386    $(document).on('keypress', '.mxchat-email-input', function(e) {
4387        if (e.key === 'Enter') {
4388            e.preventDefault();
4389            var botId = getBotIdFromElement(this);
4390            if (!emailSubmittingState[botId]) {
4391                $(this).closest('.email-collection-form').submit();
4392            }
4393        }
4394    });
4395
4396    // Handle Enter key in name input
4397    $(document).on('keypress', '.mxchat-name-input', function(e) {
4398        if (e.key === 'Enter') {
4399            e.preventDefault();
4400            var botId = getBotIdFromElement(this);
4401            if (!emailSubmittingState[botId]) {
4402                $(this).closest('.email-collection-form').submit();
4403            }
4404        }
4405    });
4406
4407    // Initialize email check for all bot instances
4408    // For floating bots: defer until widget is opened (zero passive AJAX)
4409    // For embedded bots: check immediately since the form is visible
4410    $('.mxchat-chatbot-wrapper').each(function() {
4411        var botId = $(this).data('bot-id') || 'default';
4412        var emailBlocker = getElementDOM(botId, 'email-blocker');
4413
4414        if (emailBlocker) {
4415            if (isEmbeddedBot(botId)) {
4416                // Embedded bots are always visible — check now
4417                resolveEmailState(botId);
4418            }
4419            // Floating bots: handled in the widget open handler
4420        } else if (isEmbeddedBot(botId)) {
4421            // Embedded bot, no email collection — load history with loader
4422            var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4423            if (chatPersistenceEnabled) {
4424                MxChatInstances.ensureSession(botId);
4425                showChatContainerForBot(botId);
4426            }
4427        }
4428    });
4429}
4430
4431    // Open chatbot when pre-chat message is clicked - use class selector for multi-instance
4432    $(document).on('click', '.pre-chat-message', function() {
4433        var botId = getBotIdFromElement(this);
4434        var $chatbot = getElement(botId, 'floating-chatbot');
4435        if ($chatbot.hasClass('hidden')) {
4436            $chatbot.removeClass('hidden').addClass('visible');
4437            getElement(botId, 'floating-chatbot-button').addClass('hidden');
4438            handlePreChatDismissal(botId);
4439            disableScroll(); // Disable scroll when chatbot opens
4440            mxchatFireOpened(botId);
4441
4442            // Load chat history for returning visitors (persistence)
4443            var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4444            if (chatPersistenceEnabled) {
4445                MxChatInstances.ensureSession(botId);
4446            }
4447
4448            // Deferred email check — only on first widget open
4449            var emailBlocker = getElementDOM(botId, 'email-blocker');
4450            var instance = MxChatInstances.get(botId);
4451            if (emailBlocker && !instance.emailCheckDone) {
4452                instance.emailCheckDone = true;
4453                resolveEmailState(botId);
4454            } else if (!emailBlocker) {
4455                showChatContainerForBot(botId);
4456            }
4457        }
4458    });
4459
4460    // Legacy duplicate close handler removed — handled by single event delegation above
4461
4462
4463function hasQuickQuestions(botId) {
4464    botId = botId || 'default';
4465    var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4466    if (!questionsContainer) return false;
4467    const questionButtons = questionsContainer.querySelectorAll('.mxchat-popular-question');
4468    return questionButtons.length > 0;
4469}
4470
4471/**
4472 * Check if a bot is embedded (not floating)
4473 * Embedded bots don't have a .floating-chatbot wrapper
4474 */
4475function isEmbeddedBot(botId) {
4476    botId = botId || 'default';
4477    var floatingWrapper = document.getElementById('floating-chatbot-' + botId);
4478    return !floatingWrapper;
4479}
4480
4481function collapseQuickQuestions(botId) {
4482    botId = botId || 'default';
4483    const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4484    if (questionsContainer && hasQuickQuestions(botId)) {
4485        questionsContainer.classList.add('collapsed');
4486        questionsContainer.classList.add('has-been-collapsed');
4487        try {
4488            sessionStorage.setItem('mxchat_questions_collapsed_' + botId, 'true');
4489            sessionStorage.setItem('mxchat_questions_has_been_collapsed_' + botId, 'true');
4490        } catch (e) {
4491            // Ignore if sessionStorage is not available
4492        }
4493    }
4494}
4495
4496function expandQuickQuestions(botId) {
4497    botId = botId || 'default';
4498    const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4499    if (questionsContainer && hasQuickQuestions(botId)) {
4500        questionsContainer.classList.remove('collapsed');
4501        try {
4502            sessionStorage.setItem('mxchat_questions_collapsed_' + botId, 'false');
4503        } catch (e) {
4504            // Ignore if sessionStorage is not available
4505        }
4506    }
4507}
4508
4509function checkQuickQuestionsState(botId) {
4510    botId = botId || 'default';
4511    if (!hasQuickQuestions(botId)) {
4512        return; // Don't do anything if no questions exist
4513    }
4514
4515    // Skip restoring collapsed state for embedded bots - they should always start expanded
4516    if (isEmbeddedBot(botId)) {
4517        return;
4518    }
4519
4520    try {
4521        const isCollapsed = sessionStorage.getItem('mxchat_questions_collapsed_' + botId);
4522        const hasBeenCollapsed = sessionStorage.getItem('mxchat_questions_has_been_collapsed_' + botId);
4523
4524        const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4525        if (questionsContainer) {
4526            if (hasBeenCollapsed === 'true') {
4527                questionsContainer.classList.add('has-been-collapsed');
4528            }
4529            if (isCollapsed === 'true') {
4530                questionsContainer.classList.add('collapsed');
4531            }
4532        }
4533    } catch (e) {
4534        // Ignore if sessionStorage is not available
4535    }
4536}
4537
4538//   Global delegation for dynamically added links as fallback
4539// Use class selector for multi-instance support
4540$(document).on('click', '.chat-box a[href]:not([data-tracked])', function(e) {
4541    const $link = $(this);
4542    const messageDiv = $link.closest('.bot-message, .agent-message');
4543
4544    // Only process bot/agent message links
4545    if (messageDiv.length > 0) {
4546        const originalHref = $link.attr('href');
4547
4548        if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
4549            e.preventDefault();
4550            e.stopPropagation();
4551
4552            // Mark as tracked
4553            $link.attr('data-tracked', 'true');
4554
4555            // Get bot ID from the chat box context
4556            var botId = getBotIdFromElement(this);
4557
4558            // Get message context from the message div
4559            const messageText = messageDiv.text().substring(0, 200);
4560
4561            $.ajax({
4562                url: mxchatChat.ajax_url,
4563                type: 'POST',
4564                data: {
4565                    action: 'mxchat_track_url_click',
4566                    session_id: getChatSession(botId),
4567                    url: originalHref,
4568                    message_context: messageText,
4569                    nonce: mxchatChat.nonce
4570                },
4571                complete: function() {
4572                    if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
4573                        window.open(originalHref, '_blank');
4574                    } else {
4575                        window.location.href = originalHref;
4576                    }
4577                }
4578            });
4579
4580            return false;
4581        }
4582    }
4583});
4584
4585    // ====================================
4586    // MAIN INITIALIZATION
4587    // ====================================
4588
4589    // Initialize all chatbot instances on the page
4590    initializeAllInstances();
4591
4592    // Legacy initialization for single bot compatibility
4593    $('.floating-chatbot.hidden').each(function() {
4594        var botId = getBotIdFromElement(this);
4595        getElement(botId, 'floating-chatbot-button').removeClass('hidden');
4596    });
4597
4598    // Initialize when document is ready
4599    setFullHeight();
4600
4601    // Note: trackOriginatingPage() and loadChatHistory() are now deferred
4602    // until the user's first interaction via MxChatInstances.ensureSession()
4603
4604    // Initialize chat visibility for all instances
4605    $('.mxchat-chatbot-wrapper').each(function() {
4606        var botId = $(this).data('bot-id') || 'default';
4607        initializeChatVisibility(botId);
4608        // An embedded widget is on screen from the first paint; a floating one
4609        // reports mxchat:opened when its window is shown (plan 65c9b6).
4610        if (isEmbeddedBot(botId)) {
4611            mxchatFireOpened(botId);
4612        } else {
4613            mxchatWatchForOpen(botId);
4614        }
4615    });
4616
4617    // Make functions globally available for add-ons
4618    window.hasQuickQuestions = hasQuickQuestions;
4619    window.collapseQuickQuestions = collapseQuickQuestions;
4620    window.appendMessage = appendMessage;
4621    window.appendThinkingMessage = appendThinkingMessage;
4622    window.scrollToBottom = scrollToBottom;
4623    window.scrollElementToTop = scrollElementToTop;
4624    window.replaceLastMessage = replaceLastMessage;
4625    window.callMxChat = callMxChat;
4626    window.callMxChatStream = callMxChatStream;
4627    window.shouldUseStreaming = shouldUseStreaming;
4628    window.getChatSession = getChatSession;
4629    window.getPageContext = getPageContext;
4630    window.updateStreamingMessage = updateStreamingMessage;
4631    window.MxChatInstances = MxChatInstances;
4632    window.getElement = getElement;
4633    window.getElementDOM = getElementDOM;
4634    window.getBotIdFromElement = getBotIdFromElement;
4635
4636}); // End of jQuery ready
4637
4638
4639// ====================================
4640// GLOBAL EVENT LISTENERS (Outside jQuery)
4641// ====================================
4642
4643// Event listener for copy button (code blocks)
4644document.addEventListener("click", (e) => {
4645    if (e.target.classList.contains("mxchat-copy-button")) {
4646        const copyButton = e.target;
4647        const codeBlock = copyButton
4648            .closest(".mxchat-code-block-container")
4649            .querySelector(".mxchat-code-block code");
4650
4651        if (codeBlock) {
4652            // Preserve formatting using innerText
4653            navigator.clipboard.writeText(codeBlock.innerText).then(() => {
4654                copyButton.textContent = "Copied!";
4655                copyButton.setAttribute("aria-label", "Copied to clipboard");
4656
4657                setTimeout(() => {
4658                    copyButton.textContent = "Copy";
4659                    copyButton.setAttribute("aria-label", "Copy to clipboard");
4660                }, 2000);
4661            });
4662        }
4663    }
4664});
4665
4666// ============================================================================
4667// SATISFACTION RATING (v3.2.6)
4668// ============================================================================
4669// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4670// inactivity following a bot reply. One prompt per session, deduped via
4671// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4672// the option (default off) is authoritative.
4673jQuery(function($) {
4674    if (typeof mxchatChat === 'undefined') return;
4675    // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4676    // '' and true as '1', so this must be an explicit-enable allowlist — the
4677    // old "disabled when exactly false/'off'" check let '' through and the
4678    // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4679    // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4680    // (boolean-true localizations) working.
4681    // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4682    // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4683    // merges fresh values over mxchatChat (that merge fires on first widget
4684    // open, after this module has already decided). Re-evaluating would mean
4685    // restructuring the whole module to late-bind its listeners — not worth it
4686    // for a prompt that is at worst stale for one page load on a cached page.
4687    var sre = mxchatChat.satisfaction_rating_enabled;
4688    if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4689
4690    // wp_localize_script stringifies ints, so accept both number and numeric string.
4691    var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4692    var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4693    if (!isFinite(idleSeconds)) idleSeconds = 60;
4694    if (idleSeconds < 5) idleSeconds = 5;
4695    if (idleSeconds > 600) idleSeconds = 600;
4696    var IDLE_MS = idleSeconds * 1000;
4697    var MIN_BOT_REPLIES = 2;
4698    var ratingState = {};
4699
4700    function getState(botId) {
4701        if (!ratingState[botId]) {
4702            ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4703        }
4704        return ratingState[botId];
4705    }
4706
4707    function getSessionId(botId) {
4708        if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4709            return MxChatInstances.getChatSession(botId);
4710        }
4711        return null;
4712    }
4713
4714    function isAlreadyRated(sessionId) {
4715        if (!sessionId) return false;
4716        try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4717    }
4718
4719    function markRated(sessionId) {
4720        if (!sessionId) return;
4721        try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4722    }
4723
4724    function esc(s) {
4725        return String(s == null ? '' : s)
4726            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4727            .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4728    }
4729
4730    // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4731    function ratingSkipInlineColors(botId) {
4732        if (mxchatChat.skip_inline_colors) return true;
4733        var botAssignments = mxchatChat.bot_theme_assignments || {};
4734        return botAssignments.hasOwnProperty(botId);
4735    }
4736
4737    function botBubbleStyleAttr(botId) {
4738        if (ratingSkipInlineColors(botId)) return '';
4739        var bg = mxchatChat.bot_message_bg_color;
4740        var fg = mxchatChat.bot_message_font_color;
4741        if (!bg && !fg) return '';
4742        return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4743    }
4744
4745    // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4746    // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4747    // and paints the filled "Send" pill so it fills with the bot font color and
4748    // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4749    // We paint the submit button DIRECTLY (inline longhand) rather than relying
4750    // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4751    // unreliably inside a descendant's `background`, so a bubble-level var would
4752    // silently fall back to the literal (white-block bug all over again). Inline
4753    // longhand always wins. Same transparent-guard as the menu so we never paint
4754    // a see-through value — in that case the CSS literal fallbacks keep it legible.
4755    function syncRatingBubbleColors(botId) {
4756        var $chatBox = getChatBoxByBotId(botId);
4757        if (!$chatBox || !$chatBox.length) return;
4758        var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4759        if (!bubbleEl) return;
4760        var cs = window.getComputedStyle(bubbleEl);
4761        var fg = cs.color;
4762        var bg = cs.backgroundColor;
4763        var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4764        var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4765        // Expose on the bubble too, for any inheriting styles / future use.
4766        if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4767        if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4768        // Paint the Send pill directly — the part that actually fixes the bug.
4769        var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4770        if (submitEl) {
4771            if (hasFg) submitEl.style.backgroundColor = fg; // fill  = bot font color
4772            if (hasBg) submitEl.style.color = bg;           // label = bubble background
4773        }
4774    }
4775
4776    function copy(key) {
4777        var c = mxchatChat.satisfaction_rating_copy || {};
4778        var d = {
4779            question: 'Was this helpful?',
4780            helpful: 'Helpful',
4781            not_helpful: 'Not helpful',
4782            dismiss: 'Dismiss',
4783            thanks: 'Thanks! Anything we should improve? (optional)',
4784            placeholder: 'Tell us what could be better…',
4785            send: 'Send',
4786            skip: 'Skip',
4787            saved: 'Thanks for the feedback.'
4788        };
4789        return c[key] || d[key];
4790    }
4791
4792    function thumbUpSvg() {
4793        return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 0 1 6 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75A.75.75 0 0 1 15 2a2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23h-.777Z"/><path d="M2.331 10.977a11.969 11.969 0 0 0-.831 4.398 12 12 0 0 0 .52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 0 1-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227Z"/></svg>';
4794    }
4795    function thumbDownSvg() {
4796        return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M15.73 5.25h1.035A7.465 7.465 0 0 1 18 9.375a7.465 7.465 0 0 1-1.235 4.125h-.148c-.806 0-1.534.446-2.031 1.08a9.04 9.04 0 0 1-2.861 2.4c-.723.384-1.35.956-1.653 1.715a4.498 4.498 0 0 0-.322 1.672V21a.75.75 0 0 1-.75.75 2.25 2.25 0 0 1-2.25-2.25c0-1.152.26-2.243.723-3.218.266-.558-.107-1.282-.725-1.282H3.622c-1.026 0-1.945-.694-2.054-1.715A12.137 12.137 0 0 1 1.5 12c0-2.848.992-5.464 2.649-7.521C4.537 3.997 5.136 3.75 5.754 3.75h4.541c.483 0 .964.078 1.423.23l3.114 1.04c.46.152.94.23 1.423.23Z"/><path d="M21.669 13.023c.536-1.362.831-2.845.831-4.398 0-1.22-.182-2.398-.52-3.507-.26-.85-1.084-1.368-1.973-1.368H19.1c-.445 0-.72.498-.523.898.591 1.2.924 2.55.924 3.977a8.958 8.958 0 0 1-1.302 4.666c-.245.403.028.959.5.959h1.053c.832 0 1.612-.453 1.918-1.227Z"/></svg>';
4797    }
4798
4799    function buildPromptHtml(botId) {
4800        var styleAttr = botBubbleStyleAttr(botId);
4801        return ''
4802            + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4803            +   '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4804            +     '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4805            +     '<div class="mxchat-rating-actions">'
4806            +       '<span class="mxchat-rating-buttons">'
4807            +         '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4808            +         '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4809            +       '</span>'
4810            +       '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4811            +     '</div>'
4812            +   '</div>'
4813            + '</div>';
4814    }
4815
4816    function buildFeedbackHtml(botId, rating) {
4817        var styleAttr = botBubbleStyleAttr(botId);
4818        return ''
4819            + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4820            +   '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4821            +     '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4822            +     '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4823            +     '<div class="mxchat-rating-feedback-actions">'
4824            +       '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4825            +       '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4826            +     '</div>'
4827            +   '</div>'
4828            + '</div>';
4829    }
4830
4831    function buildSavedHtml(botId) {
4832        var styleAttr = botBubbleStyleAttr(botId);
4833        return ''
4834            + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4835            +   '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4836            + '</div>';
4837    }
4838
4839    function getChatBoxByBotId(botId) {
4840        var $byId = $('#chat-box-' + botId);
4841        if ($byId.length) return $byId.first();
4842        return $('.chat-box').first();
4843    }
4844
4845    function scrollChatBoxToBottom($chatBox) {
4846        if (!$chatBox || !$chatBox.length) return;
4847        $chatBox.scrollTop($chatBox[0].scrollHeight);
4848    }
4849
4850    function showPrompt(botId) {
4851        var s = getState(botId);
4852        if (s.promptShown || s.dismissed) return;
4853        var sessionId = getSessionId(botId);
4854        if (!sessionId) return;
4855        if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4856        var $chatBox = getChatBoxByBotId(botId);
4857        if (!$chatBox.length) return;
4858        if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4859        $chatBox.append(buildPromptHtml(botId));
4860        syncRatingBubbleColors(botId);
4861        s.promptShown = true;
4862        scrollChatBoxToBottom($chatBox);
4863    }
4864
4865    function submitRating(botId, rating, feedback) {
4866        var sessionId = getSessionId(botId);
4867        if (!sessionId) return;
4868        $.post(mxchatChat.ajax_url, {
4869            action: 'mxchat_save_rating',
4870            session_id: sessionId,
4871            bot_id: botId,
4872            rating: rating,
4873            feedback: feedback || ''
4874        });
4875        markRated(sessionId);
4876    }
4877
4878    function onBotReply(botId) {
4879        var s = getState(botId);
4880        s.botReplies += 1;
4881        if (s.promptShown || s.dismissed) return;
4882        var sessionId = getSessionId(botId);
4883        if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4884        if (s.botReplies < MIN_BOT_REPLIES) return;
4885        if (s.idleTimer) clearTimeout(s.idleTimer);
4886        s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4887    }
4888
4889    function onUserMessage(botId) {
4890        var s = getState(botId);
4891        if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4892    }
4893
4894    function botIdFromChatBox(el) {
4895        var id = el && el.id ? el.id : '';
4896        return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4897    }
4898
4899    function setupObserver(chatBox) {
4900        var botId = botIdFromChatBox(chatBox);
4901        try {
4902            var observer = new MutationObserver(function(mutations) {
4903                mutations.forEach(function(m) {
4904                    for (var i = 0; i < m.addedNodes.length; i++) {
4905                        var node = m.addedNodes[i];
4906                        if (!node || node.nodeType !== 1) continue;
4907                        var $n = $(node);
4908                        if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4909                        if ($n.hasClass('bot-message')) onBotReply(botId); // count at insert time — streaming providers append with .temporary-message first, then remove later (childList observer can't see attr changes)
4910                        else if ($n.hasClass('user-message')) onUserMessage(botId);
4911                    }
4912                });
4913            });
4914            observer.observe(chatBox, { childList: true });
4915        } catch (e) { /* noop */ }
4916    }
4917
4918    $('.chat-box').each(function() { setupObserver(this); });
4919
4920    $(document).on('click', '.mxchat-rating-btn', function(e) {
4921        e.preventDefault();
4922        var $btn = $(this);
4923        var $prompt = $btn.closest('.mxchat-rating-prompt');
4924        var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4925        var botId = $prompt.data('bot-id') || 'default';
4926        var rating = parseInt($btn.attr('data-rating'), 10);
4927        if (rating !== 1 && rating !== -1) return;
4928        submitRating(botId, rating, '');
4929        ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4930        syncRatingBubbleColors(botId);
4931        scrollChatBoxToBottom(getChatBoxByBotId(botId));
4932    });
4933
4934    $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4935        e.preventDefault();
4936        var $prompt = $(this).closest('.mxchat-rating-prompt');
4937        var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4938        var botId = $prompt.data('bot-id') || 'default';
4939        var s = getState(botId);
4940        s.dismissed = true;
4941        markRated(getSessionId(botId));
4942        ($wrap.length ? $wrap : $prompt).remove();
4943    });
4944
4945    function closeFeedback($fb) {
4946        var botId = $fb.data('bot-id') || 'default';
4947        var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4948        ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4949        syncRatingBubbleColors(botId);
4950        scrollChatBoxToBottom(getChatBoxByBotId(botId));
4951    }
4952
4953    $(document).on('click', '.mxchat-rating-skip', function(e) {
4954        e.preventDefault();
4955        closeFeedback($(this).closest('.mxchat-rating-feedback'));
4956    });
4957
4958    $(document).on('click', '.mxchat-rating-submit', function(e) {
4959        e.preventDefault();
4960        var $fb = $(this).closest('.mxchat-rating-feedback');
4961        var botId = $fb.data('bot-id') || 'default';
4962        var rating = parseInt($fb.attr('data-rating'), 10);
4963        if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4964        var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4965        if (text !== '') {
4966            submitRating(botId, rating, text);
4967        }
4968        closeFeedback($fb);
4969    });
4970});
4971

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.