| @@ -541,9 +541,11 @@ | ||
| 541 | 541 | // each editor folds them into its own hidden live_agent_schedule_<channel> |
| 542 | 542 | // input and fires ONE change, which this same handler then saves. Without |
| 543 | 543 | // the exclusion each keystroke would POST a nameless field the server can |
| 544 | 544 | // only reject. |
| 545 | - $autosaveSections.find('input, textarea, select').not('#model, #openrouter_selected_model, .mxchat-la-field').on('change', function() { | |
| 545 | + // .mxchat-acf-group-toggle is excluded: group toggles are nameless and | |
| 546 | + // save through their own batch action (bf57e0), never this per-field path. | |
| 547 | + $autosaveSections.find('input, textarea, select').not('#model, #openrouter_selected_model, .mxchat-la-field, .mxchat-acf-group-toggle').on('change', function() { | |
| 546 | 548 | const $field = $(this); |
| 547 | 549 | const name = $field.attr('name'); |
| 548 | 550 | |
| 549 | 551 | // Debounce rate limit fields to prevent UI freezing from rapid changes |
| @@ -1086,12 +1088,12 @@ | ||
| 1086 | 1088 | openrouter: [ |
| 1087 | 1089 | { value: 'openrouter', label: 'OpenRouter', description: 'Access 100+ models from multiple providers (add API key to browse)' } |
| 1088 | 1090 | ], |
| 1089 | 1091 | gemini: [ |
| 1090 | - { value: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash', description: 'Stable — newest Flash generation, recommended default' }, | |
| 1092 | + { value: 'gemini-3.8-flash', label: 'Gemini 3.8 Flash', description: 'Stable — newest Flash generation, recommended default' }, | |
| 1091 | 1093 | ], |
| 1092 | 1094 | openai: [ |
| 1093 | - { value: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', description: 'Recommended — newest OpenAI flagship for reasoning, coding and chat' }, | |
| 1095 | + { value: 'gpt-6-astra', label: 'GPT-6 Astra', description: 'Recommended — newest OpenAI flagship; strongest reasoning, coding and agentic work' }, | |
| 1094 | 1096 | ], |
| 1095 | 1097 | claude: [ |
| 1096 | 1098 | { value: 'claude-fable-5', label: 'Claude Fable 5', description: 'Latest Flagship — newest and most capable Anthropic model' }, |
| 1097 | 1099 | { value: 'claude-opus-5', label: 'Claude Opus 5', description: 'Latest Opus — best for complex agentic and coding work' }, |
| @@ -3889,29 +3891,50 @@ | ||
| 3889 | 3891 | |
| 3890 | 3892 | // A successful autosave round-trip re-baselines the field it saved. The |
| 3891 | 3893 | // baseline takes the value the request actually SENT, so edits made while |
| 3892 | 3894 | // the save was in flight keep the field dirty and the guard armed. |
| 3895 | + function rebaselineField(name, saved) { | |
| 3896 | + saved = saved == null ? '' : String(saved); | |
| 3897 | + // Checkboxes go over the wire as on/off (or 1/0); normalize to 1/0. | |
| 3898 | + if (saved === 'on') saved = '1'; | |
| 3899 | + if (saved === 'off') saved = '0'; | |
| 3900 | + baseline.set(name, saved); | |
| 3901 | + const $fs = $sections.find('[name="' + name.replace(/"/g, '\\"') + '"]'); | |
| 3902 | + if ($fs.length > 1) { | |
| 3903 | + // The baseline Map is per-NAME, so duplicate names share one entry | |
| 3904 | + // and dirty-compare against each other's state — the exact shape | |
| 3905 | + // that let the exit beacon revert saves through same-named ACF | |
| 3906 | + // twins before 30e81f moved those toggles onto unique field keys. | |
| 3907 | + console.warn('MxChat: duplicate field name "' + name + '" in autosave sections — per-name dirty tracking may misreport these fields.'); | |
| 3908 | + } | |
| 3909 | + $fs.each(function() { syncBadge($(this)); }); | |
| 3910 | + } | |
| 3911 | + | |
| 3893 | 3912 | $(document).ajaxSuccess(function(event, xhr, settings) { |
| 3894 | 3913 | if (!settings || typeof settings.data !== 'string') return; |
| 3895 | - if (settings.data.indexOf('action=mxchat_save_setting') === -1 && | |
| 3914 | + const isGroupBatch = settings.data.indexOf('action=mxchat_acf_toggle_group') !== -1; | |
| 3915 | + if (!isGroupBatch && | |
| 3916 | + settings.data.indexOf('action=mxchat_save_setting') === -1 && | |
| 3896 | 3917 | settings.data.indexOf('action=mxchat_save_prompts_setting') === -1) { |
| 3897 | 3918 | return; |
| 3898 | 3919 | } |
| 3920 | + if (!xhr || !xhr.responseJSON || xhr.responseJSON.success !== true) return; | |
| 3921 | + if (isGroupBatch) { | |
| 3922 | + // A group batch (bf57e0) flips many toggles in one response — | |
| 3923 | + // re-baseline every field it touched, or the pagehide beacon | |
| 3924 | + // would post them all individually on the way out (and silently | |
| 3925 | + // drop everything past BEACON_MAX_FIELDS). | |
| 3926 | + const fields = (xhr.responseJSON.data && xhr.responseJSON.data.fields) || []; | |
| 3927 | + fields.forEach(function(f) { | |
| 3928 | + if (f && f.name) rebaselineField(f.name, f.value); | |
| 3929 | + }); | |
| 3930 | + return; | |
| 3931 | + } | |
| 3899 | 3932 | let params; |
| 3900 | 3933 | try { params = new URLSearchParams(settings.data); } catch (err) { return; } |
| 3901 | 3934 | const name = params.get('name'); |
| 3902 | 3935 | if (!name || !baseline.has(name)) return; |
| 3903 | - if (!xhr || !xhr.responseJSON || xhr.responseJSON.success !== true) return; | |
| 3904 | - let saved = params.get('value'); | |
| 3905 | - saved = saved == null ? '' : String(saved); | |
| 3906 | - // Checkboxes go over the wire as on/off (or 1/0); normalize to 1/0. | |
| 3907 | - if (saved === 'on') saved = '1'; | |
| 3908 | - if (saved === 'off') saved = '0'; | |
| 3909 | - baseline.set(name, saved); | |
| 3910 | - const $f = $sections.find('[name="' + name.replace(/"/g, '\\"') + '"]').first(); | |
| 3911 | - if ($f.length) { | |
| 3912 | - syncBadge($f); | |
| 3913 | - } | |
| 3936 | + rebaselineField(name, params.get('value')); | |
| 3914 | 3937 | }); |
| 3915 | 3938 | |
| 3916 | 3939 | // Navigation guard — recomputed per-field at the moment of leaving, so it |
| 3917 | 3940 | // arms only when a tracked value genuinely differs from last-saved. |
| @@ -4007,7 +4030,97 @@ | ||
| 4007 | 4030 | if (navigator.sendBeacon(route.url, fd)) { |
| 4008 | 4031 | sent++; |
| 4009 | 4032 | } |
| 4010 | 4033 | if (sent >= BEACON_MAX_FIELDS) return false; |
| 4034 | + }); | |
| 4035 | + }); | |
| 4036 | +}); | |
| 4037 | + | |
| 4038 | +// ─── ACF group-level toggles (plan bf57e0) ────────────────────────────── | |
| 4039 | +// One click includes/excludes every field in an ACF field group via a | |
| 4040 | +// DEDICATED batch action — one option write server-side. Looping the | |
| 4041 | +// per-field autosave endpoint from here would be a lost-update race: each | |
| 4042 | +// request reads the exclusion option before the others have written it | |
| 4043 | +// back, and the last write wins. | |
| 4044 | +jQuery(function($) { | |
| 4045 | + const $groups = $('[data-mxchat-acf-group]'); | |
| 4046 | + if (!$groups.length || typeof mxchatPromptsAdmin === 'undefined') return; | |
| 4047 | + | |
| 4048 | + function groupInputs($group) { | |
| 4049 | + return $group.find('input.mxchat-autosave-field[name^="mxchat_acf_field_"]'); | |
| 4050 | + } | |
| 4051 | + | |
| 4052 | + // Derived state, never stored: ON when nothing in the group is excluded, | |
| 4053 | + // OFF when everything is, indeterminate when mixed. Recomputed from the | |
| 4054 | + // field toggles' DOM so it can never drift from the real per-field state. | |
| 4055 | + function refreshGroupToggle($group) { | |
| 4056 | + const $toggle = $group.find('.mxchat-acf-group-toggle').first(); | |
| 4057 | + if (!$toggle.length) return; | |
| 4058 | + const $fields = groupInputs($group); | |
| 4059 | + const on = $fields.filter(':checked').length; | |
| 4060 | + $toggle.prop('indeterminate', on > 0 && on < $fields.length); | |
| 4061 | + $toggle.prop('checked', $fields.length > 0 && on === $fields.length); | |
| 4062 | + } | |
| 4063 | + | |
| 4064 | + // indeterminate is a JS-only property; the server carries the mixed | |
| 4065 | + // state via data-indeterminate on render. | |
| 4066 | + $groups.find('.mxchat-acf-group-toggle[data-indeterminate="1"]').prop('indeterminate', true); | |
| 4067 | + | |
| 4068 | + // A hand-flipped field toggle updates its group's header state right away. | |
| 4069 | + $groups.on('change', 'input.mxchat-autosave-field[name^="mxchat_acf_field_"]', function() { | |
| 4070 | + refreshGroupToggle($(this).closest('[data-mxchat-acf-group]')); | |
| 4071 | + }); | |
| 4072 | + | |
| 4073 | + $groups.on('change', '.mxchat-acf-group-toggle', function() { | |
| 4074 | + const $toggle = $(this); | |
| 4075 | + const $group = $toggle.closest('[data-mxchat-acf-group]'); | |
| 4076 | + // A click on an indeterminate box lands on checked — i.e. include | |
| 4077 | + // everything, the less destructive direction. Documented choice. | |
| 4078 | + const include = $toggle.is(':checked'); | |
| 4079 | + | |
| 4080 | + const feedbackContainer = $('<div class="feedback-container"></div>'); | |
| 4081 | + const spinner = $('<div class="saving-spinner"></div>'); | |
| 4082 | + const successIcon = $('<div class="success-icon">✔</div>'); | |
| 4083 | + $toggle.closest('.mxchat-toggle-switch').after(feedbackContainer); | |
| 4084 | + feedbackContainer.append(spinner); | |
| 4085 | + $toggle.prop('disabled', true); | |
| 4086 | + | |
| 4087 | + $.ajax({ | |
| 4088 | + url: mxchatPromptsAdmin.ajax_url, | |
| 4089 | + type: 'POST', | |
| 4090 | + data: { | |
| 4091 | + action: 'mxchat_acf_toggle_group', | |
| 4092 | + group_key: $group.attr('data-mxchat-acf-group'), | |
| 4093 | + state: include ? 'on' : 'off', | |
| 4094 | + _ajax_nonce: $toggle.data('nonce') | |
| 4095 | + }, | |
| 4096 | + success: function(response) { | |
| 4097 | + if (response && response.success) { | |
| 4098 | + const fields = (response.data && response.data.fields) || []; | |
| 4099 | + fields.forEach(function(f) { | |
| 4100 | + if (!f || !f.name) return; | |
| 4101 | + $group.find('[name="' + f.name.replace(/"/g, '\\"') + '"]').prop('checked', f.value === 'on'); | |
| 4102 | + }); | |
| 4103 | + refreshGroupToggle($group); | |
| 4104 | + spinner.fadeOut(200, function() { | |
| 4105 | + feedbackContainer.append(successIcon); | |
| 4106 | + successIcon.fadeIn(200).delay(1000).fadeOut(200, function() { | |
| 4107 | + feedbackContainer.remove(); | |
| 4108 | + }); | |
| 4109 | + }); | |
| 4110 | + } else { | |
| 4111 | + feedbackContainer.remove(); | |
| 4112 | + refreshGroupToggle($group); // fall back to the fields' real state | |
| 4113 | + alert('Error saving: ' + ((response && response.data && response.data.message) || 'Unknown error')); | |
| 4114 | + } | |
| 4115 | + }, | |
| 4116 | + error: function() { | |
| 4117 | + feedbackContainer.remove(); | |
| 4118 | + refreshGroupToggle($group); | |
| 4119 | + alert('Error saving: request failed'); | |
| 4120 | + }, | |
| 4121 | + complete: function() { | |
| 4122 | + $toggle.prop('disabled', false); | |
| 4123 | + } | |
| 4011 | 4124 | }); |
| 4012 | 4125 | }); |
| 4013 | 4126 | }); |