diff --git a/html/css/app.css b/html/css/app.css
index e06f5bd..5bf81be 100644
--- a/html/css/app.css
+++ b/html/css/app.css
@@ -411,6 +411,19 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
border-color: var(--text-muted);
}
+/* Destructive action (discard draft, delete favorite, etc). Muted so it
+ doesn't overpower the primary CTA — the color is the signal. */
+.btn-danger {
+ background: transparent;
+ color: #b91c1c;
+ border-color: #fecaca;
+}
+.btn-danger:hover {
+ background: #fef2f2;
+ border-color: #f87171;
+ color: #991b1b;
+}
+
.btn-ghost {
background: transparent;
color: var(--text-muted);
@@ -598,6 +611,13 @@ table.dataTable.no-footer { border-bottom: none; }
margin-top: 0.6em;
}
+/* Push destructive actions inside the modal footer to the far left so the
+ affirmative "Send" / "Keep editing" pair stays on the right and the user
+ can't fat-finger Discard on their way to Send. */
+.modal__actions .btn-danger {
+ margin-right: auto;
+}
+
/* ------- Responsive ------------------------------------------------------ */
@media (max-width: 640px) {
diff --git a/html/sendMessage.html b/html/sendMessage.html
index c88bc8f..e087d66 100644
--- a/html/sendMessage.html
+++ b/html/sendMessage.html
@@ -81,6 +81,7 @@
+
@@ -92,6 +93,7 @@
Ready to send?
This message will be sent.
+
diff --git a/html/sendMessage.js b/html/sendMessage.js
index aa33f55..b798bd2 100644
--- a/html/sendMessage.js
+++ b/html/sendMessage.js
@@ -243,6 +243,41 @@ function loadNewGroups(appName) {
});
}
+// ---- cancel / discard -----------------------------------------------------
+
+// POSTs /jobs/cancel to drop the caller's in-progress "building" job for
+// this bot. Idempotent server-side, so it's safe to call whether or not
+// the user has already hit "Review & send" (which is what creates the
+// building entry). Fires an unawaited request — clearing the local form
+// doesn't depend on the response.
+function requestServerCancel() {
+ fetch('/CollabCentral/' + CollabCentral.appName + '/jobs/cancel', { method: 'POST' })
+ .catch(function (err) { console.error('Cancel request failed:', err); });
+}
+
+// Cancel button on the compose form. Confirms with the user (a stray
+// click shouldn't nuke a half-written draft), tells the server to drop
+// any in-progress building entry, then clears the local form.
+function cancelMessage() {
+ var hasDraft = !!message.value()
+ || document.getElementById('uploadImage').files.length
+ || document.getElementById('uploadCSV').files.length
+ || document.querySelector('#favGroups').getSelectedOptions().length
+ || document.querySelector('#newGroups').getSelectedOptions().length
+ || document.getElementById('scheduledFor').value;
+ if (hasDraft && !window.confirm('Discard this message?')) return;
+ requestServerCancel();
+ clearForm();
+}
+
+// "Discard" action from inside the review modal — same as Cancel but
+// also closes the modal (which is open at this point).
+function discardMessage() {
+ requestServerCancel();
+ clearForm();
+ closeModal();
+}
+
// ---- send-flow modal ------------------------------------------------------
function submit() {
@@ -286,4 +321,11 @@ function clearForm() {
var preview = document.getElementById('file-ip-1-preview');
preview.removeAttribute('src');
preview.style.display = 'none';
+ // VirtualSelect exposes setValue([]) to clear a multi-select without
+ // reinitializing the widget. Guard for the case where init hasn't run
+ // yet (e.g. clearForm() called before the group lists finished loading).
+ var fav = document.querySelector('#favGroups');
+ if (fav && typeof fav.setValue === 'function') fav.setValue([]);
+ var newG = document.querySelector('#newGroups');
+ if (newG && typeof newG.setValue === 'function') newG.setValue([]);
}
diff --git a/index.js b/index.js
index c33d675..f637b18 100644
--- a/index.js
+++ b/index.js
@@ -356,6 +356,29 @@ app.post('/CollabCentral/:app/jobs/:action', (req, res) => {
delete jobs.building[buildingKey(req)];
saveConfig(jobs, './config/jobs.json')
res.status(204).redirect("/CollabCentral/" + req.params.app + "/monitorJobs.html");
+ } else if (req.params.action == "cancel") {
+ // Abandon the caller's in-progress "building" job for this bot.
+ // A building entry exists as soon as the user clicks "Review &
+ // send" (POST /jobs/edit) and lingers on the server until the
+ // user promotes it to running/scheduled — or until now, where
+ // this route just drops it on the floor. Idempotent: 200 whether
+ // or not a building entry actually existed for (personId, app),
+ // so the client can always fire this on "cancel" without needing
+ // to check state first.
+ logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/cancel");
+ var key = buildingKey(req);
+ var hadJob = !!jobs.building[key];
+ if (hadJob) {
+ delete jobs.building[key];
+ try {
+ saveConfig(jobs, './config/jobs.json');
+ } catch (err) {
+ logger('apiEndpoint(' + req.params.app + ')',
+ 'jobs/cancel save failed: ' + (err && err.message || err));
+ return res.status(500).send('Failed to cancel job.');
+ }
+ }
+ return res.status(200).send({ cancelled: hadJob });
} else { res.status(404) }
} else { res.status(401).send("You are not authorized.") }