diff --git a/agent/css/ticket.css b/agent/css/ticket.css
index 2c8d5171c..22f9923f4 100644
--- a/agent/css/ticket.css
+++ b/agent/css/ticket.css
@@ -72,3 +72,18 @@
min-width: 45%;
}
}
+
+/*
+ * Bootstrap cancels hover on any .btn following a .btn-check (twbs #31149 / PR #37026):
+ * a checked outline toggle and an unchecked one looked identical while hovered, so you
+ * could not read the control's state without moving the pointer away. Correct, but the
+ * selector also catches the UNCHECKED buttons, and with nothing preselected here that
+ * leaves the whole strip with no feedback at all.
+ *
+ * Give the unchecked ones a NEUTRAL tint - not the variant fill, which is what checked
+ * looks like and would reintroduce exactly the ambiguity #31149 was about. Upstream's
+ * color and border-color still apply, so only the background moves.
+ */
+#replyTypePicker .btn-check:not(:checked) + .btn:hover {
+ background-color: var(--bs-secondary-bg);
+}
diff --git a/agent/post/ticket.php b/agent/post/ticket.php
index 5bad5f6f6..e573b303e 100644
--- a/agent/post/ticket.php
+++ b/agent/post/ticket.php
@@ -1865,9 +1865,10 @@ if (isset($_POST['add_ticket_reply'])) {
// Defaults
$send_email = 0;
$ticket_reply_id = 0;
- if ($_POST['public_reply_type'] == 1 ){
+ $public_reply_type = intval($_POST['public_reply_type'] ?? 0);
+ if ($public_reply_type == 1) {
$ticket_reply_type = 'Public';
- } elseif ($_POST['public_reply_type'] == 2 ) {
+ } elseif ($public_reply_type == 2) {
$ticket_reply_type = 'Public';
$send_email = 1;
} else {
diff --git a/agent/ticket.php b/agent/ticket.php
index e412f0104..7ebbe3a8e 100644
--- a/agent/ticket.php
+++ b/agent/ticket.php
@@ -715,11 +715,15 @@ if (isset($_GET['ticket_id'])) {
-
-
+
+
-
+
@@ -727,103 +731,114 @@ if (isset($_GET['ticket_id'])) {
-
+
- /*
- * Canned responses offered on this ticket: the ones tied to its
- * category, plus the general ones that are offered everywhere.
- * Names only - the body is fetched when one is picked, so a
- * shelf of long responses does not ride along with every ticket.
- */
- $sql_canned_responses = mysqli_query($mysqli, "SELECT canned_response_id, canned_response_name, canned_response_category_id
- FROM canned_responses
- WHERE canned_response_archived_at IS NULL
- AND (canned_response_category_id = 0 OR canned_response_category_id = $ticket_category)
- ORDER BY canned_response_name ASC");
+
+ while ($canned_row = mysqli_fetch_assoc($sql_canned_responses)) {
+ if (intval($canned_row['canned_response_category_id']) === 0) {
+ $canned_responses_general[] = $canned_row;
+ } else {
+ $canned_responses_for_category[] = $canned_row;
+ }
+ }
-
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
@@ -1510,6 +1525,53 @@ require_once "../includes/footer.php";
});
}
+ // Reply composer - the type buttons above are the entry point. The composer stays
+ // collapsed until one is picked, so the conversation is not pushed down the page and
+ // a public reply is never the default.
+ const replyComposer = document.getElementById('replyComposer');
+ if (replyComposer) {
+ const replyComposerCollapse = bootstrap.Collapse.getOrCreateInstance(replyComposer, { toggle: false });
+ const replyTypes = document.querySelectorAll('input[name="public_reply_type"]');
+
+ // Only ever opens. Switching Internal -> Public mid-draft must not throw the draft away.
+ replyTypes.forEach(radio => radio.addEventListener('change', () => replyComposerCollapse.show()));
+
+ replyComposer.addEventListener('shown.bs.collapse', function () {
+ // TinyMCE's autoresize plugin measured the editor while its container was
+ // display:none, so it sized to nothing. Re-measure now the box is real.
+ const editor = window.tinymce ? tinymce.get('ticket_reply') : null;
+
+ if (editor) {
+ editor.execCommand('mceAutoResize');
+ editor.focus();
+ } else {
+ const textarea = document.getElementById('ticket_reply');
+ if (textarea) textarea.focus();
+ }
+ });
+
+ const cancelReply = document.getElementById('cancelReply');
+ if (cancelReply) {
+ cancelReply.addEventListener('click', function () {
+ replyComposerCollapse.hide();
+ replyTypes.forEach(radio => { radio.checked = false; });
+ });
+ }
+
+ // The status select is required and lives inside the collapse. A required control in a
+ // display:none container blocks submission with nothing on screen to explain it, and the
+ // form can still be submitted with Enter from a focused reply-type button, so refuse
+ // outright while the composer is shut.
+ const replyForm = replyComposer.closest('form');
+ if (replyForm) {
+ replyForm.addEventListener('submit', function (e) {
+ if (!replyComposer.classList.contains('show')) {
+ e.preventDefault();
+ }
+ });
+ }
+ }
+
// Conversation filter - show everything, only what the client can see, or only internal notes
const replyFilter = document.getElementById('replyFilter');
if (replyFilter) {