PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / trunk
MxChat – AI Chatbot & Content Generation for WordPress vtrunk
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | js/chat-script.js +807 -131 3.2.6trunk View file →
@@ -1,41 +1,166 @@
1 1 jQuery(document).ready(function($) {
2 2
3 - // Nonce refresh is deferred until first user interaction (ensureSession)
4 - // to avoid admin-ajax calls on passive page loads. The state machine below
5 - // queues callbacks so a chat-send that fires while the refresh AJAX is still
6 - // in flight waits for the fresh nonce instead of racing it with the stale
7 - // cached value (which would 403 as "Access denied" on the first message).
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;
8 19 var nonceRefreshState = 'idle'; // 'idle' | 'pending' | 'done'
9 20 var nonceRefreshCallbacks = [];
10 - function refreshNonceIfNeeded(callback) {
11 - if (typeof mxchatChat === 'undefined' || !mxchatChat.ajax_url) {
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') {
12 55 if (callback) callback();
13 56 return;
14 57 }
15 - if (nonceRefreshState === 'done') {
58 + var now = Date.now();
59 + if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 + mxchatChat.nonce = cachedFreshNonce;
16 61 if (callback) callback();
17 62 return;
18 63 }
19 64 if (callback) nonceRefreshCallbacks.push(callback);
20 - if (nonceRefreshState === 'pending') {
21 - return;
22 - }
65 + if (nonceRefreshState === 'pending') return;
23 66 nonceRefreshState = 'pending';
24 - $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' })
25 - .done(function(res) {
26 - if (res && res.success && res.data && res.data.nonce) {
27 - mxchatChat.nonce = res.data.nonce;
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);
28 98 }
29 - })
30 - .always(function() {
31 - nonceRefreshState = 'done';
32 - var pending = nonceRefreshCallbacks;
33 - nonceRefreshCallbacks = [];
34 - pending.forEach(function(cb) { try { cb(); } catch (e) {} });
35 99 });
36 100 }
37 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 +
38 163 // ====================================
39 164 // MULTI-INSTANCE MANAGEMENT SYSTEM
40 165 // ====================================
41 166
@@ -159,9 +284,20 @@
159 284 var newSessionId = generateSessionId();
160 285 this.setChatSession(botId, newSessionId);
161 286 var $chatBox = getElement(botId, 'chat-box');
162 287 if ($chatBox.length) {
163 - $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
288 + // Keep the greeting, drop everything else. Identify the greeting
289 + // by its marker class, NOT by position (plan a1a79b): after a
290 + // chat-persistence restore the first .bot-message is a real
291 + // reply, so ":not(:first)" left a stale answer sitting at the
292 + // top of an otherwise empty box. The positional fallback only
293 + // runs when the marker is absent — a page served from HTML cache
294 + // that predates this release — and behaves exactly as before.
295 + if ($chatBox.find('.mxchat-intro-message').length) {
296 + $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove();
297 + } else {
298 + $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
299 + }
164 300 }
165 301 if (this.instances[botId]) {
166 302 this.instances[botId].chatHistoryLoaded = false;
167 303 this.instances[botId].processedMessageIds = new Set();
@@ -218,8 +354,20 @@
218 354 var id = $floating.attr('id') || '';
219 355 var match = id.match(/floating-chatbot-(.+)/);
220 356 if (match) return match[1];
221 357 }
358 + // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
359 + // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
360 + // .close-pre-chat-message button, which carries only a class and no id — miss both
361 + // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
362 + // and read the suffix. (closest() includes the element itself, so a click directly on
363 + // #pre-chat-message-{bot_id} resolves here too.)
364 + var $preChat = $(element).closest('[id^="pre-chat-message-"]');
365 + if ($preChat.length) {
366 + var preId = $preChat.attr('id') || '';
367 + var preMatch = preId.match(/^pre-chat-message-(.+)$/);
368 + if (preMatch) return preMatch[1];
369 + }
222 370 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
223 371 var elementId = $(element).attr('id') || '';
224 372 if (elementId) {
225 373 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -259,9 +407,27 @@
259 407 if (parts.length == 2) return parts.pop().split(";").shift();
260 408 }
261 409
262 410 function generateSessionId() {
263 - return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9);
411 + // Session IDs function as the de-facto bearer token for an anonymous
412 + // chat, so generate them with a CSPRNG when available. Math.random is a
413 + // legacy fallback for ancient/sandboxed environments that lack
414 + // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other
415 + // code pattern-matches on it). (plan-0c17b5)
416 + var rand;
417 + try {
418 + if (window.crypto && window.crypto.getRandomValues) {
419 + var buf = new Uint8Array(16); // 128 bits
420 + window.crypto.getRandomValues(buf);
421 + rand = Array.prototype.map.call(buf, function (b) {
422 + return ('0' + b.toString(16)).slice(-2);
423 + }).join('');
424 + }
425 + } catch (e) {}
426 + if (!rand) {
427 + rand = Math.random().toString(36).substr(2, 9); // legacy fallback
428 + }
429 + return 'mxchat_chat_' + rand;
264 430 }
265 431
266 432 // Legacy function - now delegates to instance manager
267 433 function getChatSession(botId) {
@@ -464,8 +630,26 @@
464 630 sendButton.style.pointerEvents = 'none';
465 631 }
466 632 }
467 633
634 +// Whether the input may grab focus after a completed reply (plan 03799f).
635 +// On coarse-pointer devices focusing a text input summons the on-screen
636 +// keyboard over the answer the visitor is trying to read, so 'auto' (the
637 +// default) focuses only on fine-pointer devices. The site-wide
638 +// mxchat_autofocus_after_reply PHP filter can force 'on'/'off'.
639 +// NOT used on widget open (:~3424) — that focus is a deliberate act and is
640 +// what makes the widget keyboard-accessible.
641 +function mxchatShouldAutofocusAfterReply() {
642 + var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto';
643 + if (pref === 'on') return true;
644 + if (pref === 'off') return false;
645 + try {
646 + return !window.matchMedia('(pointer: coarse)').matches;
647 + } catch (err) {
648 + return true;
649 + }
650 +}
651 +
468 652 function enableChatInput(botId) {
469 653 botId = botId || 'default';
470 654 var chatInput = getElementDOM(botId, 'chat-input');
471 655 var sendButton = getElementDOM(botId, 'send-button');
@@ -471,9 +655,11 @@
471 655 var sendButton = getElementDOM(botId, 'send-button');
472 656 if (chatInput) {
473 657 chatInput.disabled = false;
474 658 chatInput.style.opacity = '1';
475 - chatInput.focus();
659 + if (mxchatShouldAutofocusAfterReply()) {
660 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
661 + }
476 662 }
477 663 if (sendButton) {
478 664 sendButton.disabled = false;
479 665 sendButton.style.opacity = '1';
@@ -478,10 +664,102 @@
478 664 sendButton.disabled = false;
479 665 sendButton.style.opacity = '1';
480 666 sendButton.style.pointerEvents = 'auto';
481 667 }
668 + // Every completion path re-enables input, so this is the single restore
669 + // point for the streaming Stop affordance (no-op when not in stop mode).
670 + mxchatRestoreSendButton(botId);
482 671 }
483 672
673 +// --- Streaming Stop control -------------------------------------------------
674 +// One live stream handle per bot instance, so Stop on one widget never aborts
675 +// another bot on the same page.
676 +var mxchatActiveStreams = {};
677 +// Original send-button markup, captured once per bot the first time the Stop
678 +// state is shown (never captured while already in stop mode, so a rapid
679 +// stop-then-resend can't save the stop glyph as the "original").
680 +var mxchatSendMarkup = {};
681 +
682 +function mxchatShowStopButton(botId) {
683 + var btn = getElementDOM(botId, 'send-button');
684 + if (!btn) return;
685 + if (!btn.classList.contains('mxchat-stop-mode')) {
686 + mxchatSendMarkup[botId] = {
687 + html: btn.innerHTML,
688 + label: btn.getAttribute('aria-label')
689 + };
690 + }
691 +
692 + // Mirror the send icon's rendered size + color so the stop glyph looks
693 + // native, including custom send images/colors and theme overrides.
694 + var child = btn.querySelector('svg, img');
695 + var size = 25;
696 + var color = '';
697 + if (child) {
698 + var rect = child.getBoundingClientRect();
699 + if (rect.width) {
700 + size = Math.round(Math.min(rect.width, rect.height));
701 + }
702 + var cs = window.getComputedStyle(child);
703 + color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
704 + }
705 + var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
706 + 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>';
707 + // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
708 + // start a stream synchronously while the originating click is still
709 + // bubbling up to our delegated handler. Without this guard, that handler
710 + // reads the just-added stop-mode class as a user Stop press and aborts the
711 + // brand-new stream — the user's message renders but no reply ever fires
712 + // (plan-4bba64 silent message loss). The flag only spans the current event
713 + // dispatch: cleared on the next macrotask, long before a real Stop click.
714 + btn.__mxchatStopJustShown = true;
715 + setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
716 + btn.classList.add('mxchat-stop-mode');
717 + btn.setAttribute('aria-label', stopLabel);
718 + btn.setAttribute('title', stopLabel);
719 + // disableChatInput() ran when the turn was sent; the Stop control itself
720 + // must stay clickable while the textarea remains disabled.
721 + btn.disabled = false;
722 + btn.style.opacity = '1';
723 + btn.style.pointerEvents = 'auto';
724 +}
725 +
726 +function mxchatRestoreSendButton(botId) {
727 + var btn = getElementDOM(botId, 'send-button');
728 + var saved = mxchatSendMarkup[botId];
729 + if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
730 + btn.innerHTML = saved.html;
731 + btn.classList.remove('mxchat-stop-mode');
732 + btn.removeAttribute('title');
733 + if (saved.label) {
734 + btn.setAttribute('aria-label', saved.label);
735 + }
736 +}
737 +
738 +function mxchatStopStreaming(botId) {
739 + var entry = mxchatActiveStreams[botId];
740 + if (!entry || !entry.controller) return;
741 + entry.aborted = true;
742 + try { entry.controller.abort(); } catch (e) {}
743 +}
744 +
745 +// Returns true when a stream rejection came from an intentional Stop click:
746 +// keep the partial text as the turn's answer — no error UI, no fallback resend.
747 +function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
748 + var entry = mxchatActiveStreams[botId];
749 + if (!entry || !entry.aborted) return false;
750 + delete mxchatActiveStreams[botId];
751 + if (!accumulatedContent) {
752 + // Stopped before the first chunk: drop the thinking bubble, no orphan message.
753 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
754 + }
755 + enableChatInput(botId); // also restores the send icon
756 + if (callback) {
757 + callback(accumulatedContent || '');
758 + }
759 + return true;
760 +}
761 +
484 762 // Update your existing sendMessage function
485 763 function sendMessage(botId) {
486 764 botId = botId || 'default';
487 765 MxChatInstances.ensureSession(botId);
@@ -502,8 +780,9 @@
502 780 }
503 781
504 782 appendMessage("user", message, '', [], false, botId);
505 783 $chatInput.val('');
784 + mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
506 785 $chatInput.css('height', 'auto');
507 786
508 787 if (hasQuickQuestions(botId)) {
509 788 collapseQuickQuestions(botId);
@@ -510,14 +789,16 @@
510 789 }
511 790 appendThinkingMessage(botId);
512 791 scrollToBottom(botId);
513 792
514 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
793 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
515 794
516 795 // Check if streaming is enabled AND supported for this model
517 796 if (shouldUseStreaming(currentModel)) {
518 797 callMxChatStream(message, function(response) {
519 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
798 + // Content is final: releasing aria-busy lets the live region
799 + // announce the completed reply once (plan 67f126).
800 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
520 801 }, botId);
521 802 } else {
522 803 callMxChat(message, function(response) {
523 804 replaceLastMessage("bot", response, '', [], botId);
@@ -550,14 +831,15 @@
550 831 }
551 832 appendThinkingMessage(botId);
552 833 scrollToBottom(botId);
553 834
554 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
835 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
555 836
556 837 // Check if streaming is enabled AND supported for this model
557 838 if (shouldUseStreaming(currentModel)) {
558 839 callMxChatStream(message, function(response) {
559 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
840 + // Final content — release aria-busy so the reply announces once (67f126).
841 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
560 842 }, botId);
561 843 } else {
562 844 callMxChat(message, function(response) {
563 845 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -621,8 +903,15 @@
621 903
622 904 function callMxChat(message, callback, botId) {
623 905 botId = botId || getMxChatBotId();
624 906
907 + // Streaming fallbacks land here: drop any leftover stream handle and
908 + // return the button to its send state (no-op for plain non-stream turns).
909 + if (mxchatActiveStreams[botId]) {
910 + delete mxchatActiveStreams[botId];
911 + }
912 + mxchatRestoreSendButton(botId);
913 +
625 914 // Store the message in case we need to retry after session reset
626 915 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
627 916
628 917 // Get page context if contextual awareness is enabled
@@ -720,12 +1009,13 @@
720 1009 // Re-send the original message with the new session (user message is already displayed)
721 1010 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
722 1011 if (originalMessage) {
723 1012 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
724 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1013 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
725 1014 if (shouldUseStreaming(currentModel)) {
726 1015 callMxChatStream(originalMessage, function(response) {
727 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
1016 + // Final content — release aria-busy so the reply announces once (67f126).
1017 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
728 1018 }, botId);
729 1019 } else {
730 1020 callMxChat(originalMessage, function(response) {
731 1021 replaceLastMessage("bot", response, '', [], botId);
@@ -873,9 +1163,9 @@
873 1163
874 1164 // Store the message in case we need to retry after session reset
875 1165 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
876 1166
877 - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1167 + const currentModel = mxchatChat.model || 'gpt-5.6-sol';
878 1168 if (!isStreamingSupported(currentModel)) {
879 1169 callMxChat(message, callback, botId);
880 1170 return;
881 1171 }
@@ -928,13 +1218,25 @@
928 1218
929 1219 let accumulatedContent = '';
930 1220 let testingDataReceived = false;
931 1221 let streamingStarted = false;
1222 + // Server-pushed html to append as its OWN bot bubble once the stream
1223 + // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1224 + // deferred to [DONE] so the embed always lands BELOW the streamed text.
1225 + let pendingAppendHtml = '';
932 1226
1227 + // Abortable stream: a fresh controller per turn, keyed by bot instance.
1228 + // The Stop control (send button swapped in place) aborts both the read
1229 + // loop and the underlying request.
1230 + var streamControl = { controller: new AbortController(), aborted: false };
1231 + mxchatActiveStreams[botId] = streamControl;
1232 + mxchatShowStopButton(botId);
1233 +
933 1234 fetch(mxchatChat.ajax_url, {
934 1235 method: 'POST',
935 1236 body: formData,
936 - credentials: 'same-origin'
1237 + credentials: 'same-origin',
1238 + signal: streamControl.controller.signal
937 1239 })
938 1240 .then(response => {
939 1241 // Store the response for potential fallback handling
940 1242 const responseClone = response.clone();
@@ -1035,8 +1337,17 @@
1035 1337
1036 1338 // Re-enable chat input after streaming completes
1037 1339 enableChatInput(botId);
1038 1340
1341 + // Render any server-pushed appendix html (e.g. the
1342 + // YouTube embed) as its own bot bubble below the
1343 + // streamed text — mirrors how it is saved in the
1344 + // transcript, so history replays identically.
1345 + if (pendingAppendHtml) {
1346 + appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1347 + pendingAppendHtml = '';
1348 + }
1349 +
1039 1350 // Scroll the user's last message to the top now
1040 1351 // that the bot's full reply has rendered.
1041 1352 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1042 1353 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
@@ -1070,8 +1381,21 @@
1070 1381 streamingStarted = true;
1071 1382 accumulatedContent += json.content;
1072 1383 updateStreamingMessage(accumulatedContent, botId);
1073 1384 }
1385 + // Stash appendix html (e.g. video embed) for [DONE]
1386 + else if (json.append_html) {
1387 + pendingAppendHtml = json.append_html;
1388 + }
1389 + // Server-side final pass changed the assembled text
1390 + // (ffef6f: dead-link stripping) — swap the rendered
1391 + // bubble for the validated version. Arrives at most
1392 + // once, just before [DONE].
1393 + else if (json.replace_content) {
1394 + streamingStarted = true;
1395 + accumulatedContent = json.replace_content;
1396 + updateStreamingMessage(accumulatedContent, botId);
1397 + }
1074 1398 // Handle complete response in stream (fallback response)
1075 1399 else if (json.text || json.message || json.html) {
1076 1400 handleNonStreamResponse(json, callback, botId);
1077 1401 return;
@@ -1101,8 +1425,9 @@
1101 1425 }
1102 1426
1103 1427 processStream();
1104 1428 }).catch(streamError => {
1429 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1105 1430 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1106 1431 callMxChat(message, callback, botId);
1107 1432 });
1108 1433 }
@@ -1109,8 +1434,9 @@
1109 1434
1110 1435 processStream();
1111 1436 })
1112 1437 .catch(error => {
1438 + if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1113 1439 // Check if we have server error data with chat mode
1114 1440 if (error && error.isServerError && error.data) {
1115 1441 // Check for chat mode in error data
1116 1442 if (error.data.chat_mode) {
@@ -1171,9 +1497,9 @@
1171 1497 // Re-send the original message with the new session (user message is already displayed)
1172 1498 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1173 1499 if (originalMessage) {
1174 1500 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1175 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1501 + var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1176 1502 if (shouldUseStreaming(currentModel)) {
1177 1503 callMxChatStream(originalMessage, callback, botId);
1178 1504 } else {
1179 1505 callMxChat(originalMessage, callback, botId);
@@ -1306,8 +1632,15 @@
1306 1632 var $chatBox = getElement(botId, 'chat-box');
1307 1633 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1308 1634
1309 1635 if (tempMessage.length) {
1636 + // aria-busy=true for the whole stream: the bubble is rewritten on
1637 + // every chunk, and without busy a polite live region announces those
1638 + // rewrites continuously. Flipped false once the reply is final, so
1639 + // assistive tech announces the completed message ONCE (plan 67f126).
1640 + if (tempMessage.attr('aria-busy') !== 'true') {
1641 + tempMessage.attr('aria-busy', 'true');
1642 + }
1310 1643 // Update existing message
1311 1644 tempMessage.html(formattedContent);
1312 1645 } else {
1313 1646 // Create new temporary message if it doesn't exist
@@ -1334,8 +1667,17 @@
1334 1667 // Update the event handlers to use the correct function names (using event delegation)
1335 1668 // Use class-based selectors for multi-instance support
1336 1669 $(document).on('click', '.send-button', function() {
1337 1670 var botId = getBotIdFromElement(this);
1671 + // While a response is streaming the button is a Stop control.
1672 + if (this.classList.contains('mxchat-stop-mode')) {
1673 + // Same click that just started this stream (an add-on's direct handler
1674 + // ran before this delegated one) — not a Stop press. See
1675 + // mxchatShowStopButton for the full story (plan-4bba64).
1676 + if (this.__mxchatStopJustShown) return;
1677 + mxchatStopStreaming(botId);
1678 + return;
1679 + }
1338 1680 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1339 1681 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1340 1682 disableChatInput(botId);
1341 1683 }
@@ -1354,8 +1696,73 @@
1354 1696 sendMessage(botId);
1355 1697 }
1356 1698 });
1357 1699
1700 +// Chat input character counter + soft limit feedback (plan 7091a2).
1701 +// Language-neutral: numbers + color only, no translatable strings. The counter
1702 +// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1703 +// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1704 +// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1705 +function mxchatUpdateCharCounter(inputEl) {
1706 + if (!inputEl || !inputEl.closest) return;
1707 + var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1708 + var container = inputEl.closest('.input-container');
1709 + if (!container || !max || max <= 0) return;
1710 + var counter = container.querySelector('.mxchat-char-counter');
1711 + if (!counter) return;
1712 + var len = inputEl.value.length;
1713 + var ratio = len / max;
1714 + var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1715 + var cur = counter.querySelector('.mxchat-char-counter-current');
1716 + if (cur) cur.textContent = len;
1717 + var warn = ratio >= nearThreshold && len < max;
1718 + var full = len >= max;
1719 + counter.classList.toggle('is-visible', ratio >= nearThreshold);
1720 + counter.classList.toggle('is-warn', warn);
1721 + counter.classList.toggle('is-full', full);
1722 + container.classList.toggle('mxchat-input-near-limit', warn);
1723 + container.classList.toggle('mxchat-input-at-limit', full);
1724 +}
1725 +
1726 +function mxchatBumpInput(inputEl) {
1727 + var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1728 + if (!container) return;
1729 + container.classList.remove('mxchat-input-bump');
1730 + void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1731 + container.classList.add('mxchat-input-bump');
1732 + clearTimeout($(container).data('mxchatBumpTimeout'));
1733 + var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1734 + $(container).data('mxchatBumpTimeout', t);
1735 +}
1736 +
1737 +// Live counter update on every input.
1738 +$(document).on('input', '.chat-input', function() {
1739 + mxchatUpdateCharCounter(this);
1740 +});
1741 +
1742 +// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1743 +// rejected at the cap (maxlength silently swallows it otherwise).
1744 +$(document).on('keydown', '.chat-input', function(e) {
1745 + var max = parseInt(this.getAttribute('maxlength'), 10);
1746 + if (!max || max <= 0 || this.value.length < max) return;
1747 + if (e.ctrlKey || e.metaKey || e.altKey) return;
1748 + // A single printable char with no selection to overwrite WILL be rejected.
1749 + if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1750 + mxchatBumpInput(this);
1751 + }
1752 +});
1753 +
1754 +// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1755 +$(document).on('paste', '.chat-input', function() {
1756 + var el = this;
1757 + var max = parseInt(el.getAttribute('maxlength'), 10);
1758 + if (!max || max <= 0) return;
1759 + setTimeout(function() {
1760 + mxchatUpdateCharCounter(el);
1761 + if (el.value.length >= max) mxchatBumpInput(el);
1762 + }, 0);
1763 +});
1764 +
1358 1765 // Builds the list of overflow-menu items for a given bot.
1359 1766 // Adding a future item is one push to this array — do NOT hardcode "only download."
1360 1767 function mxchatGetHeaderMenuItems(botId) {
1361 1768 var items = [];
@@ -1373,8 +1780,26 @@
1373 1780 }
1374 1781 });
1375 1782 }
1376 1783
1784 + // "Start new chat" — surfaces the EXISTING per-conversation reset
1785 + // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1786 + // without the site owner disabling chat persistence globally. Default OFF;
1787 + // gated by the reset_chat_enabled option. plan ac2e81.
1788 + if (settings.reset_chat_enabled === 'on') {
1789 + items.push({
1790 + id: 'reset-chat',
1791 + label: settings.reset_chat_label || 'Start new chat',
1792 + 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>',
1793 + action: function() {
1794 + var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1795 + if (window.confirm(confirmMsg)) {
1796 + MxChatInstances.resetChatSession(botId);
1797 + }
1798 + }
1799 + });
1800 + }
1801 +
1377 1802 return items;
1378 1803 }
1379 1804
1380 1805 // Builds a clean markdown transcript of the current conversation and triggers
@@ -1452,30 +1877,31 @@
1452 1877 var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1453 1878 if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1454 1879 }
1455 1880
1456 -// One-time per-widget init: renders menu items, wires open/close,
1457 -// outside-click, Escape, and arrow-key navigation. If no items, hides the trigger.
1458 -function mxchatInitHeaderMenu(botId) {
1459 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1460 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1461 -
1881 +// Renders (or re-renders) the item list for one menu wrap. Split out of
1882 +// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1883 +// rebuild items + trigger visibility WITHOUT re-binding the one-time
1884 +// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1885 +// a rebuild before init (never happens, but harmless) just skips it.
1886 +function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1462 1887 var $trigger = $wrap.find('.mxchat-menu-trigger');
1463 1888 var $menu = $wrap.find('.mxchat-header-menu');
1464 1889 var items = mxchatGetHeaderMenuItems(botId);
1465 1890
1466 - // Initial color sync — covers normal page load.
1467 - mxchatSyncMenuColors(botId, $wrap);
1891 + $menu.empty();
1468 1892
1469 1893 if (!items.length) {
1470 1894 $trigger.hide();
1471 1895 $menu.hide();
1472 - $wrap.data('mxchatMenuReady', true);
1473 1896 return;
1474 1897 }
1475 1898
1476 - // Build the menu items.
1477 - $menu.empty();
1899 + // Clear any inline display:none a previous zero-item render left behind —
1900 + // open/close visibility is governed by the hidden prop + is-open class.
1901 + $trigger.css('display', '');
1902 + $menu.css('display', '');
1903 +
1478 1904 items.forEach(function(item, idx) {
1479 1905 var $btn = $('<button>', {
1480 1906 type: 'button',
1481 1907 'class': 'mxchat-menu-item',
@@ -1488,14 +1914,50 @@
1488 1914 $btn.find('.mxchat-menu-item-label').text(item.label);
1489 1915 $btn.on('click', function(e) {
1490 1916 e.preventDefault();
1491 1917 e.stopPropagation();
1492 - closeMenu();
1918 + if (closeMenuFn) closeMenuFn();
1493 1919 try { item.action(); } catch (err) { /* no-op */ }
1494 1920 });
1495 1921 $menu.append($btn);
1496 1922 });
1923 +}
1497 1924
1925 +// Re-render every menu on the page after a dynamic-settings merge
1926 +// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
1927 +// swapping items under the user mid-interaction yanks focus — and the
1928 +// rebuild runs when it closes instead (closeMenu checks the pending flag).
1929 +function mxchatRebuildHeaderMenus() {
1930 + $('.mxchat-header-menu-wrap').each(function() {
1931 + var $wrap = $(this);
1932 + var botId = $wrap.data('bot-id');
1933 + if (!botId) return;
1934 + if (!$wrap.data('mxchatMenuReady')) {
1935 + mxchatInitHeaderMenu(botId);
1936 + return;
1937 + }
1938 + if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
1939 + $wrap.data('mxchatMenuRebuildPending', true);
1940 + return;
1941 + }
1942 + mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
1943 + });
1944 +}
1945 +
1946 +// One-time per-widget init: renders menu items, wires open/close,
1947 +// outside-click, Escape, and arrow-key navigation. If no items, hides the
1948 +// trigger. Wiring happens even when there are zero items at init, so a
1949 +// later dynamic-settings rebuild that adds items has a working trigger.
1950 +function mxchatInitHeaderMenu(botId) {
1951 + var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1952 + if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1953 +
1954 + var $trigger = $wrap.find('.mxchat-menu-trigger');
1955 + var $menu = $wrap.find('.mxchat-header-menu');
1956 +
1957 + // Initial color sync — covers normal page load.
1958 + mxchatSyncMenuColors(botId, $wrap);
1959 +
1498 1960 function openMenu() {
1499 1961 // Re-sync each open in case the active theme changed since init.
1500 1962 mxchatSyncMenuColors(botId, $wrap);
1501 1963 $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
@@ -1509,8 +1971,14 @@
1509 1971 $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1510 1972 $trigger.attr('aria-expanded', 'false');
1511 1973 $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1512 1974 if (returnFocus) $trigger.trigger('focus');
1975 + // A dynamic-settings rebuild that arrived while the menu was open
1976 + // was deferred (mxchatRebuildHeaderMenus) — run it now.
1977 + if ($wrap.data('mxchatMenuRebuildPending')) {
1978 + $wrap.removeData('mxchatMenuRebuildPending');
1979 + mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1980 + }
1513 1981 }
1514 1982
1515 1983 // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1516 1984 // click-to-collapse handler does not fire.
@@ -1564,8 +2032,13 @@
1564 2032 openMenu();
1565 2033 }
1566 2034 });
1567 2035
2036 + // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
2037 + // then do the initial item render.
2038 + $wrap.data('mxchatMenuClose', closeMenu);
2039 + mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2040 +
1568 2041 $wrap.data('mxchatMenuReady', true);
1569 2042 }
1570 2043
1571 2044 // Initialize header menus for every rendered widget on DOM ready.
@@ -1573,8 +2046,18 @@
1573 2046 $('.mxchat-header-menu-wrap').each(function() {
1574 2047 var botId = $(this).data('bot-id');
1575 2048 if (botId) mxchatInitHeaderMenu(botId);
1576 2049 });
2050 +
2051 + // Embedded (non-floating) widgets are open from the moment the page
2052 + // renders — refresh dynamic settings at init (plan-32db95). Floating
2053 + // widgets refresh on first launcher open instead.
2054 + var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
2055 + return !$(this).closest('.floating-chatbot').length;
2056 + }).length > 0;
2057 + if (hasEmbeddedWidget) {
2058 + mxchatRefreshDynamicSettings();
2059 + }
1577 2060 });
1578 2061
1579 2062 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1580 2063 try {
@@ -1651,9 +2134,11 @@
1651 2134
1652 2135 messageDiv.html(fullMessage);
1653 2136
1654 2137 if (isTemporary) {
1655 - messageDiv.addClass('temporary-message');
2138 + // In-flight bubble: hold aria-busy so the live region stays quiet
2139 + // until the content is finalized (plan 67f126).
2140 + messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
1656 2141 }
1657 2142
1658 2143 // Append to the correct chatbot instance's chat-box
1659 2144 var $chatBox = getElement(botId, 'chat-box');
@@ -1788,13 +2273,16 @@
1788 2273 }
1789 2274
1790 2275 if (lastMessageDiv.length) {
1791 2276 // Replace content immediately to prevent visual gap between thinking dots and response
2277 + // aria-busy released AFTER the final content is set, so the live region
2278 + // announces the finished message once (plan 67f126).
1792 2279 lastMessageDiv
1793 2280 .html(fullMessage)
1794 2281 .removeClass('bot-message user-message temporary-message')
1795 2282 .addClass(messageClass)
1796 - .attr('dir', 'auto');
2283 + .attr('dir', 'auto')
2284 + .attr('aria-busy', 'false');
1797 2285
1798 2286 // Only apply inline colors if AI theme is not active (let CSS handle it)
1799 2287 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
1800 2288 if (!skipColors) {
@@ -1854,10 +2342,15 @@
1854 2342 var botMessageFontColor = mxchatChat.bot_message_font_color;
1855 2343 var botMessageBgColor = mxchatChat.bot_message_bg_color;
1856 2344
1857 2345 // Build thinking dots HTML - skip inline colors if AI theme is active
2346 + // The dots are decorative; the sr-only span is what the live region
2347 + // announces for the waiting state (plan 67f126). Server-localized
2348 + // string — safe to inject (esc_html__ output, no user content).
1858 2349 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
1859 - var thinkingHtml = '<div class="thinking-dots-container">' +
2350 + var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2351 + var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2352 + '<div class="thinking-dots-container" aria-hidden="true">' +
1860 2353 '<div class="thinking-dots">' +
1861 2354 '<span class="dot"' + dotStyle + '></span>' +
1862 2355 '<span class="dot"' + dotStyle + '></span>' +
1863 2356 '<span class="dot"' + dotStyle + '></span>' +
@@ -2557,9 +3050,30 @@
2557 3050 }
2558 3051
2559 3052 // Only process if there are actual messages
2560 3053 if (response.data.conversation.length > 0) {
2561 - // IMPORTANT: Clear existing messages before loading history
3054 + // Restored history must be SILENT to screen readers
3055 + // (plan 67f126): these are DOM additions inside the
3056 + // live region and would otherwise announce as if
3057 + // they just arrived. Lift aria-live for the batch
3058 + // repopulate, restore it after the browser has
3059 + // processed the mutations.
3060 + var mxLiveRegionEl = $chatBox.get(0);
3061 + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3062 + if (mxLiveRegionEl) {
3063 + mxLiveRegionEl.setAttribute('aria-live', 'off');
3064 + }
3065 +
3066 + // IMPORTANT: Clear existing messages before loading history.
3067 + // Detach the greeting first and put it back below —
3068 + // it is server-rendered and never stored in the
3069 + // transcript, so the old unconditional .empty()
3070 + // deleted it for the rest of the page life (plan
3071 + // a1a79b). Detach rather than rebuild: intro_message
3072 + // is not localized to JS, and this node already
3073 + // carries the per-bot inline colors and any
3074 + // {visitor_name} substitution already applied to it.
3075 + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
2562 3076 $chatBox.empty();
2563 3077
2564 3078 $.each(response.data.conversation, function(index, message) {
2565 3079 // Skip agent messages if persistence is off
@@ -2599,12 +3113,16 @@
2599 3113
2600 3114 // Skip linkify for messages containing structured HTML
2601 3115 // (forms, product cards, galleries, etc.) to avoid
2602 3116 // markdown formatting corrupting HTML attributes
2603 - // (e.g. underscores in name="field_name" becoming <em> tags)
2604 - if (content.includes("mxchat-product-card") ||
2605 - content.includes("mxchat-image-gallery") ||
2606 - content.includes("mxchat-featured-products") ||
3117 + // (e.g. underscores in name="field_name" becoming <em> tags).
3118 + // One family check instead of a per-card literal list: any
3119 + // element carrying an mxchat- prefixed class is MxChat-generated
3120 + // structured markup and replays raw. The old list drifted every
3121 + // time an add-on minted a new card class — the filtered-search
3122 + // card ("mxchat-filtered-product-card") missed it and replayed
3123 + // through linkify as visible markup.
3124 + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
2607 3125 content.includes("<form") ||
2608 3126 content.includes("<input") ||
2609 3127 content.includes("<select") ||
2610 3128 content.includes("<textarea")) {
@@ -2622,12 +3140,29 @@
2622 3140 instance.processedMessageIds.add(message.id);
2623 3141 }
2624 3142 });
2625 3143
2626 - // Only append messages and scroll if we have content
3144 + // Only append messages and scroll if we have content.
3145 + // Greeting goes back FIRST, above the restored
3146 + // history: "Hello — [earlier conversation]" is the
3147 + // natural reading and matches the order a fresh
3148 + // visitor sees (plan a1a79b).
3149 + if ($mxIntro && $mxIntro.length) {
3150 + $chatBox.append($mxIntro);
3151 + }
2627 3152 $chatBox.append($fragment);
2628 3153 scrollToBottom(botId, true);
2629 3154
3155 + // Re-attach live semantics AFTER the rehydration
3156 + // mutations have been processed with the region off
3157 + // (plan 67f126). Restoring later announces nothing
3158 + // retroactively; new turns announce normally.
3159 + if (mxLiveRegionEl) {
3160 + setTimeout(function() {
3161 + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3162 + }, 200);
3163 + }
3164 +
2630 3165 // Collapse quick questions if we have conversation history
2631 3166 // BUT skip auto-collapse for embedded bots (they should stay expanded)
2632 3167 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
2633 3168 collapseQuickQuestions(botId);
@@ -2722,10 +3257,10 @@
2722 3257 .then(data => {
2723 3258 if (data.success) {
2724 3259 container.style.display = 'none';
2725 3260 nameElement.textContent = '';
2726 - activePdfFile = null;
2727 - appendMessage('bot', 'PDF removed.');
3261 + instance.activePdfFile = null;
3262 + appendMessage('bot', 'PDF removed.', '', [], false, botId);
2728 3263 }
2729 3264 })
2730 3265 .catch(error => {
2731 3266 // Error removing PDF - silently continue
@@ -2731,14 +3266,16 @@
2731 3266 // Error removing PDF - silently continue
2732 3267 });
2733 3268 }
2734 3269
2735 - function removeActiveWord() {
2736 - const container = document.getElementById('active-word-container');
2737 - const nameElement = document.getElementById('active-word-name');
2738 -
2739 - if (!container || !nameElement || !activeWordFile) return;
2740 -
3270 + function removeActiveWord(botId) {
3271 + botId = botId || 'default';
3272 + var instance = MxChatInstances.get(botId);
3273 + const container = getElementDOM(botId, 'active-word-container');
3274 + const nameElement = getElementDOM(botId, 'active-word-name');
3275 +
3276 + if (!container || !nameElement || !instance.activeWordFile) return;
3277 +
2741 3278 fetch(mxchatChat.ajax_url, {
2742 3279 method: 'POST',
2743 3280 headers: {
2744 3281 'Content-Type': 'application/x-www-form-urlencoded',
@@ -2744,9 +3281,9 @@
2744 3281 'Content-Type': 'application/x-www-form-urlencoded',
2745 3282 },
2746 3283 body: new URLSearchParams({
2747 3284 'action': 'mxchat_remove_word',
2748 - 'session_id': sessionId,
3285 + 'session_id': getChatSession(botId),
2749 3286 'nonce': mxchatChat.nonce
2750 3287 })
2751 3288 })
2752 3289 .then(response => response.json())
@@ -2753,10 +3290,10 @@
2753 3290 .then(data => {
2754 3291 if (data.success) {
2755 3292 container.style.display = 'none';
2756 3293 nameElement.textContent = '';
2757 - activeWordFile = null;
2758 - appendMessage('bot', 'Word document removed.');
3294 + instance.activeWordFile = null;
3295 + appendMessage('bot', 'Word document removed.', '', [], false, botId);
2759 3296 }
2760 3297 })
2761 3298 .catch(error => {
2762 3299 // Error removing Word document - silently continue
@@ -2915,11 +3452,38 @@
2915 3452 e.stopPropagation();
2916 3453 var botId = getBotIdFromElement(this);
2917 3454 collapseQuickQuestions(botId);
2918 3455 });
3456 +
3457 +// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3458 +// thumbnail facade — no Google iframe exists until the visitor taps play.
3459 +// Delegated so it also works for embeds restored from chat history.
3460 +$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3461 + e.preventDefault();
3462 + var $wrap = $(this).closest('.mxchat-youtube-embed');
3463 + var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3464 + if (!videoId) {
3465 + return;
3466 + }
3467 + var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3468 + var $iframe = $('<iframe>', {
3469 + src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3470 + title: title,
3471 + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3472 + allowfullscreen: true,
3473 + frameborder: 0
3474 + }).addClass('mxchat-youtube-iframe');
3475 + $wrap.addClass('mxchat-youtube-playing');
3476 + $(this).replaceWith($iframe);
3477 +});
2919 3478
2920 3479 // Chatbot visibility toggle handlers - use class selector for multi-instance support
2921 - $(document).on('click', '.floating-chatbot-button', function() {
3480 + // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3481 + $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3482 + if (e.type === 'keydown') {
3483 + if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3484 + e.preventDefault();
3485 + }
2922 3486 var botId = getBotIdFromElement(this);
2923 3487 var $chatbot = getElement(botId, 'floating-chatbot');
2924 3488 var $badge = getElement(botId, 'chat-notification-badge');
2925 3489 var $preChat = getElement(botId, 'pre-chat-message');
@@ -2924,14 +3488,20 @@
2924 3488 var $badge = getElement(botId, 'chat-notification-badge');
2925 3489 var $preChat = getElement(botId, 'pre-chat-message');
2926 3490
2927 3491 if ($chatbot.hasClass('hidden')) {
2928 - $chatbot.removeClass('hidden').addClass('visible');
2929 - $(this).addClass('hidden');
3492 + $chatbot.removeClass('hidden').addClass('visible')
3493 + .attr('aria-modal', 'true').attr('role', 'dialog');
3494 + $(this).addClass('hidden').attr('aria-expanded', 'true');
2930 3495 $badge.hide(); // Hide notification when opening chat
2931 3496 disableScroll();
2932 3497 $preChat.fadeOut(250);
2933 3498
3499 + // First open per page load: re-fetch behavior settings in case
3500 + // this page's inline values came from a stale full-page cache
3501 + // (plan-32db95). Idempotent — later opens are a no-op.
3502 + mxchatRefreshDynamicSettings();
3503 +
2934 3504 // Load chat history for returning visitors (persistence)
2935 3505 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
2936 3506 if (chatPersistenceEnabled) {
2937 3507 MxChatInstances.ensureSession(botId);
@@ -2947,24 +3517,55 @@
2947 3517 // No email collection — still route through showChatContainerForBot
2948 3518 // so the loader is shown while chat history loads
2949 3519 showChatContainerForBot(botId);
2950 3520 }
3521 +
3522 + // Move keyboard focus into the message input after the open transition.
3523 + setTimeout(function() {
3524 + var chatInput = getElementDOM(botId, 'chat-input');
3525 + if (chatInput && !chatInput.disabled) {
3526 + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3527 + }
3528 + }, 300);
2951 3529 } else {
2952 - $chatbot.removeClass('visible').addClass('hidden');
2953 - $(this).removeClass('hidden');
3530 + $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3531 + $(this).removeClass('hidden').attr('aria-expanded', 'false');
2954 3532 enableScroll();
2955 3533 checkPreChatDismissal(botId);
2956 3534 }
2957 3535 });
2958 3536
2959 - // Allow clicking anywhere on the title bar to close the chatbot
3537 + // Allow clicking anywhere on the title bar to close the chatbot.
3538 + // Returns keyboard focus to the launcher so keyboard users don't get
3539 + // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3540 + // heuristic-based so mouse-triggered close won't show a focus ring.
2960 3541 $(document).on('click', '.chatbot-top-bar', function() {
2961 3542 var botId = getBotIdFromElement(this);
2962 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2963 - getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3543 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3544 + var $launcher = getElement(botId, 'floating-chatbot-button');
3545 + $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2964 3546 enableScroll();
3547 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
2965 3548 });
2966 3549
3550 + // Global Escape-key handler — closes any visible chat widget and
3551 + // returns focus to its launcher. Standard modal-dismissal pattern;
3552 + // pairs with aria-modal="true" set on the widget when it opens.
3553 + $(document).on('keydown', function(e) {
3554 + if (e.key !== 'Escape' && e.key !== 'Esc') return;
3555 + var $visible = $('.floating-chatbot.visible');
3556 + if (!$visible.length) return;
3557 + e.preventDefault();
3558 + $visible.each(function() {
3559 + var botId = getBotIdFromElement(this);
3560 + $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3561 + var $launcher = getElement(botId, 'floating-chatbot-button');
3562 + $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3563 + try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3564 + });
3565 + enableScroll();
3566 + });
3567 +
2967 3568 $(document).on('click', '.close-pre-chat-message', function(e) {
2968 3569 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
2969 3570 var botId = getBotIdFromElement(this);
2970 3571 handlePreChatDismissal(botId);
@@ -2984,17 +3585,20 @@
2984 3585 var wordInput = getElementDOM(botId, 'word-upload');
2985 3586 if (wordInput) wordInput.click();
2986 3587 });
2987 3588
2988 - // PDF file input change handler
2989 - addSafeEventListener('pdf-upload', 'change', async function(e) {
2990 - const file = e.target.files[0];
2991 -
3589 + // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3590 + $(document).on('change', '.pdf-upload', async function(e) {
3591 + var botId = getBotIdFromElement(this);
3592 + var instance = MxChatInstances.get(botId);
3593 + const file = this.files[0];
3594 + const sessionId = MxChatInstances.ensureSession(botId);
3595 +
2992 3596 if (!file || file.type !== 'application/pdf') {
2993 3597 alert('Please select a valid PDF file.');
2994 3598 return;
2995 3599 }
2996 -
3600 +
2997 3601 if (!sessionId) {
2998 3602 alert('Error: No session ID found');
2999 3603 return;
3000 3604 }
@@ -3002,14 +3606,15 @@
3002 3606 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3003 3607 alert('Error: Ajax configuration missing');
3004 3608 return;
3005 3609 }
3006 -
3610 +
3007 3611 // Disable buttons and show loading state
3008 - const uploadBtn = document.getElementById('pdf-upload-btn');
3009 - const sendBtn = document.getElementById('send-button');
3612 + const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3613 + const sendBtn = getElementDOM(botId, 'send-button');
3614 + if (!uploadBtn) return;
3010 3615 const originalBtnContent = uploadBtn.innerHTML;
3011 -
3616 +
3012 3617 try {
3013 3618 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3014 3619 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3015 3620 const formData = new FormData();
@@ -3016,35 +3621,34 @@
3016 3621 formData.append('action', 'mxchat_upload_pdf');
3017 3622 formData.append('pdf_file', file);
3018 3623 formData.append('session_id', sessionId);
3019 3624 formData.append('nonce', mxchatChat.nonce);
3020 -
3625 +
3021 3626 uploadBtn.disabled = true;
3022 - sendBtn.disabled = true;
3627 + if (sendBtn) sendBtn.disabled = true;
3023 3628 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3024 3629 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3025 3630 </svg>`;
3026 -
3631 +
3027 3632 const response = await fetch(mxchatChat.ajax_url, {
3028 3633 method: 'POST',
3029 3634 body: formData
3030 3635 });
3031 -
3636 +
3032 3637 const data = await response.json();
3033 -
3638 +
3034 3639 if (data.success) {
3035 3640 // Hide popular questions if they exist
3036 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
3037 - if (hasQuickQuestions()) {
3038 - collapseQuickQuestions();
3641 + if (hasQuickQuestions(botId)) {
3642 + collapseQuickQuestions(botId);
3039 3643 }
3040 -
3644 +
3041 3645 // Show the active PDF name
3042 - showActivePdf(data.data.filename);
3043 -
3044 - appendMessage('bot', data.data.message);
3045 - scrollToBottom();
3046 - activePdfFile = data.data.filename;
3646 + showActivePdf(data.data.filename, botId);
3647 +
3648 + appendMessage('bot', data.data.message, '', [], false, botId);
3649 + scrollToBottom(botId);
3650 + instance.activePdfFile = data.data.filename;
3047 3651 } else {
3048 3652 alert('Failed to upload PDF. Please try again.');
3049 3653 }
3050 3654 } catch (error) {
@@ -3050,33 +3654,42 @@
3050 3654 } catch (error) {
3051 3655 alert('Error uploading file. Please try again.');
3052 3656 } finally {
3053 3657 uploadBtn.disabled = false;
3054 - sendBtn.disabled = false;
3658 + if (sendBtn) sendBtn.disabled = false;
3055 3659 uploadBtn.innerHTML = originalBtnContent;
3056 3660 this.value = ''; // Reset file input
3057 3661 }
3058 3662 });
3059 3663
3060 - // Word file input change handler
3061 - addSafeEventListener('word-upload', 'change', async function(e) {
3062 - const file = e.target.files[0];
3063 -
3664 + // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3665 + $(document).on('change', '.word-upload', async function(e) {
3666 + var botId = getBotIdFromElement(this);
3667 + var instance = MxChatInstances.get(botId);
3668 + const file = this.files[0];
3669 + const sessionId = MxChatInstances.ensureSession(botId);
3670 +
3064 3671 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3065 3672 alert('Please select a valid Word document (.docx).');
3066 3673 return;
3067 3674 }
3068 -
3675 +
3069 3676 if (!sessionId) {
3070 3677 alert('Error: No session ID found');
3071 3678 return;
3072 3679 }
3073 3680
3681 + if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3682 + alert('Error: Ajax configuration missing');
3683 + return;
3684 + }
3685 +
3074 3686 // Disable buttons and show loading state
3075 - const uploadBtn = document.getElementById('word-upload-btn');
3076 - const sendBtn = document.getElementById('send-button');
3687 + const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3688 + const sendBtn = getElementDOM(botId, 'send-button');
3689 + if (!uploadBtn) return;
3077 3690 const originalBtnContent = uploadBtn.innerHTML;
3078 -
3691 +
3079 3692 try {
3080 3693 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3081 3694 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3082 3695 const formData = new FormData();
@@ -3083,35 +3696,34 @@
3083 3696 formData.append('action', 'mxchat_upload_word');
3084 3697 formData.append('word_file', file);
3085 3698 formData.append('session_id', sessionId);
3086 3699 formData.append('nonce', mxchatChat.nonce);
3087 -
3700 +
3088 3701 uploadBtn.disabled = true;
3089 - sendBtn.disabled = true;
3702 + if (sendBtn) sendBtn.disabled = true;
3090 3703 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3091 3704 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3092 3705 </svg>`;
3093 -
3706 +
3094 3707 const response = await fetch(mxchatChat.ajax_url, {
3095 3708 method: 'POST',
3096 3709 body: formData
3097 3710 });
3098 -
3711 +
3099 3712 const data = await response.json();
3100 -
3713 +
3101 3714 if (data.success) {
3102 3715 // Hide popular questions if they exist
3103 - const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
3104 - if (hasQuickQuestions()) {
3105 - collapseQuickQuestions();
3716 + if (hasQuickQuestions(botId)) {
3717 + collapseQuickQuestions(botId);
3106 3718 }
3107 -
3719 +
3108 3720 // Show the active Word document name
3109 - showActiveWord(data.data.filename);
3110 -
3111 - appendMessage('bot', data.data.message);
3112 - scrollToBottom();
3113 - activeWordFile = data.data.filename;
3721 + showActiveWord(data.data.filename, botId);
3722 +
3723 + appendMessage('bot', data.data.message, '', [], false, botId);
3724 + scrollToBottom(botId);
3725 + instance.activeWordFile = data.data.filename;
3114 3726 } else {
3115 3727 alert('Failed to upload Word document. Please try again.');
3116 3728 }
3117 3729 } catch (error) {
@@ -3117,25 +3729,25 @@
3117 3729 } catch (error) {
3118 3730 alert('Error uploading file. Please try again.');
3119 3731 } finally {
3120 3732 uploadBtn.disabled = false;
3121 - sendBtn.disabled = false;
3733 + if (sendBtn) sendBtn.disabled = false;
3122 3734 uploadBtn.innerHTML = originalBtnContent;
3123 3735 this.value = ''; // Reset file input
3124 3736 }
3125 3737 });
3126 3738
3127 - // Remove button click handlers
3128 - document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3739 + // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3740 + $(document).on('click', '.remove-pdf-btn', function(e) {
3129 3741 e.preventDefault();
3130 3742 e.stopPropagation();
3131 - removeActivePdf();
3743 + removeActivePdf(getBotIdFromElement(this));
3132 3744 });
3133 -
3134 - document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3745 +
3746 + $(document).on('click', '.remove-word-btn', function(e) {
3135 3747 e.preventDefault();
3136 3748 e.stopPropagation();
3137 - removeActiveWord();
3749 + removeActiveWord(getBotIdFromElement(this));
3138 3750 });
3139 3751
3140 3752 // Window resize handlers
3141 3753 $(window).on('resize orientationchange', function() {
@@ -3279,10 +3891,13 @@
3279 3891 function replaceVisitorNamePlaceholder(botId, visitorName) {
3280 3892 var chatBox = getElementDOM(botId, 'chat-box');
3281 3893 if (!chatBox) return;
3282 3894
3283 - // Find the first bot message (intro message)
3284 - var introMessage = chatBox.querySelector('.bot-message');
3895 + // Find the greeting by its marker, not by position (plan a1a79b) —
3896 + // after a persistence restore the first .bot-message is a restored
3897 + // reply, and {visitor_name} was being substituted into that instead.
3898 + // Positional fallback for HTML cached before this release only.
3899 + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
3285 3900 if (!introMessage) return;
3286 3901
3287 3902 var messageContent = introMessage.querySelector('div[dir="auto"]');
3288 3903 if (!messageContent) return;
@@ -3454,8 +4069,9 @@
3454 4069 }
3455 4070
3456 4071 var emailInput = getElementDOM(botId, 'user-email');
3457 4072 var nameInput = getElementDOM(botId, 'user-name');
4073 + var consentInput = getElementDOM(botId, 'user-consent');
3458 4074 var userEmail = emailInput ? emailInput.value.trim() : '';
3459 4075 var userName = nameInput ? nameInput.value.trim() : '';
3460 4076 var sessionId = MxChatInstances.ensureSession(botId);
3461 4077
@@ -3475,8 +4091,15 @@
3475 4091 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3476 4092 return false;
3477 4093 }
3478 4094
4095 + // Consent checkbox (b062c4): backstop behind the native required
4096 + // attribute; the server enforces this independently either way.
4097 + if (consentInput && consentInput.required && !consentInput.checked) {
4098 + showEmailError(botId, 'Please tick the consent box to continue.');
4099 + return false;
4100 + }
4101 +
3479 4102 clearEmailError(botId);
3480 4103 setEmailSubmissionState(botId, true);
3481 4104
3482 4105 // Prepare form data
@@ -3490,8 +4113,14 @@
3490 4113 if (userName) {
3491 4114 formData.append('name', userName);
3492 4115 }
3493 4116
4117 + // Ticked/unticked both travel when the checkbox is rendered, so an
4118 + // optional-consent "no" is recorded as a decision, not an absence.
4119 + if (consentInput) {
4120 + formData.append('consent', consentInput.checked ? '1' : '0');
4121 + }
4122 +
3494 4123 fetch(mxchatChat.ajax_url, {
3495 4124 method: 'POST',
3496 4125 headers: {
3497 4126 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3836,13 +4465,26 @@
3836 4465 // SATISFACTION RATING (v3.2.6)
3837 4466 // ============================================================================
3838 4467 // Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
3839 4468 // inactivity following a bot reply. One prompt per session, deduped via
3840 -// localStorage. Disabled site-wide when mxchatChat.satisfaction_rating_enabled
3841 -// is exactly false (default ON).
4469 +// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4470 +// the option (default off) is authoritative.
3842 4471 jQuery(function($) {
3843 4472 if (typeof mxchatChat === 'undefined') return;
3844 - if (mxchatChat.satisfaction_rating_enabled === false || mxchatChat.satisfaction_rating_enabled === 'off') return;
4473 + // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4474 + // '' and true as '1', so this must be an explicit-enable allowlist — the
4475 + // old "disabled when exactly false/'off'" check let '' through and the
4476 + // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4477 + // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4478 + // (boolean-true localizations) working.
4479 + // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4480 + // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4481 + // merges fresh values over mxchatChat (that merge fires on first widget
4482 + // open, after this module has already decided). Re-evaluating would mean
4483 + // restructuring the whole module to late-bind its listeners — not worth it
4484 + // for a prompt that is at worst stale for one page load on a cached page.
4485 + var sre = mxchatChat.satisfaction_rating_enabled;
4486 + if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
3845 4487
3846 4488 // wp_localize_script stringifies ints, so accept both number and numeric string.
3847 4489 var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
3848 4490 var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
@@ -3897,8 +4539,39 @@
3897 4539 if (!bg && !fg) return '';
3898 4540 return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
3899 4541 }
3900 4542
4543 + // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4544 + // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4545 + // and paints the filled "Send" pill so it fills with the bot font color and
4546 + // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4547 + // We paint the submit button DIRECTLY (inline longhand) rather than relying
4548 + // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4549 + // unreliably inside a descendant's `background`, so a bubble-level var would
4550 + // silently fall back to the literal (white-block bug all over again). Inline
4551 + // longhand always wins. Same transparent-guard as the menu so we never paint
4552 + // a see-through value — in that case the CSS literal fallbacks keep it legible.
4553 + function syncRatingBubbleColors(botId) {
4554 + var $chatBox = getChatBoxByBotId(botId);
4555 + if (!$chatBox || !$chatBox.length) return;
4556 + var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4557 + if (!bubbleEl) return;
4558 + var cs = window.getComputedStyle(bubbleEl);
4559 + var fg = cs.color;
4560 + var bg = cs.backgroundColor;
4561 + var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4562 + var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4563 + // Expose on the bubble too, for any inheriting styles / future use.
4564 + if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4565 + if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4566 + // Paint the Send pill directly — the part that actually fixes the bug.
4567 + var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4568 + if (submitEl) {
4569 + if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4570 + if (hasBg) submitEl.style.color = bg; // label = bubble background
4571 + }
4572 + }
4573 +
3901 4574 function copy(key) {
3902 4575 var c = mxchatChat.satisfaction_rating_copy || {};
3903 4576 var d = {
3904 4577 question: 'Was this helpful?',
@@ -3981,8 +4654,9 @@
3981 4654 var $chatBox = getChatBoxByBotId(botId);
3982 4655 if (!$chatBox.length) return;
3983 4656 if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
3984 4657 $chatBox.append(buildPromptHtml(botId));
4658 + syncRatingBubbleColors(botId);
3985 4659 s.promptShown = true;
3986 4660 scrollChatBoxToBottom($chatBox);
3987 4661 }
3988 4662
@@ -4050,8 +4724,9 @@
4050 4724 var rating = parseInt($btn.attr('data-rating'), 10);
4051 4725 if (rating !== 1 && rating !== -1) return;
4052 4726 submitRating(botId, rating, '');
4053 4727 ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4728 + syncRatingBubbleColors(botId);
4054 4729 scrollChatBoxToBottom(getChatBoxByBotId(botId));
4055 4730 });
4056 4731
4057 4732 $(document).on('click', '.mxchat-rating-dismiss', function(e) {
@@ -4068,8 +4743,9 @@
4068 4743 function closeFeedback($fb) {
4069 4744 var botId = $fb.data('bot-id') || 'default';
4070 4745 var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4071 4746 ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4747 + syncRatingBubbleColors(botId);
4072 4748 scrollChatBoxToBottom(getChatBoxByBotId(botId));
4073 4749 }
4074 4750
4075 4751 $(document).on('click', '.mxchat-rating-skip', function(e) {