Add a cancel button to the compose page

Users can now abandon a draft they don't want to send. Two entry
points, one shared server action.

Backend
- New action on the existing POST /CollabCentral/:app/jobs/:action
  route: /jobs/cancel. Deletes jobs.building[<personId>::<appName>]
  if present, saves jobs.json, returns 200 with { cancelled: true }.
- Idempotent: if there was no building entry (client fires this on
  every cancel click regardless of state), the endpoint no-ops with
  { cancelled: false } and never touches jobs.json. That keeps disk
  writes tied to real state changes instead of every click.
- Wrapped saveConfig in try/catch so a disk-full or permission
  failure returns a clean 500 instead of crashing the request.

Frontend
- New "Cancel" button in the compose form's action row, sits next
  to "Review & send" as a secondary action.
- cancelMessage() checks whether the form has anything in it (text,
  image, CSV, groups, schedule) and only prompts window.confirm() if
  there's actually a draft to lose. Blank-form clicks skip the
  prompt so the button behaves as expected on first load.
- New "Discard" action on the review modal for "wait, actually no"
  decisions after clicking Review. Positioned on the far left of the
  modal footer with margin-right: auto so it's visually separated
  from the affirmative "Keep editing" / "Send" pair — a stray click
  on the way to Send lands on Keep editing, not Discard.
- Extended clearForm() to also clear both VirtualSelect group
  pickers via setValue([]) (previously only text/files/schedule got
  reset, leaving stale group selections after a cancel or send).
- New .btn-danger style: muted red text on a transparent background,
  filled-red hover state. Used for the modal Discard button and
  available for future destructive UI.

Not covered (follow-up if wanted): the preview DM the bot sends to
the sender during /jobs/edit isn't tracked in the building job, so
Cancel doesn't retract that Webex message. Adding server-side
tracking of the preview message id would let cancel call
DELETE /v1/messages/<id> to clean it up.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joseph B. McQueen 2026-07-01 20:32:50 -04:00
parent 524b443dce
commit 224e853368
4 changed files with 87 additions and 0 deletions

View file

@ -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) {

View file

@ -81,6 +81,7 @@
</div>
<div class="formActions">
<button id="cancel" type="button" class="btn btn-secondary" onclick="cancelMessage()">Cancel</button>
<button id="submit" type="submit" class="btn btn-primary">Review &amp; send</button>
</div>
</form>
@ -92,6 +93,7 @@
<h3>Ready to send?</h3>
<p id="modalMessage">This message will be sent.</p>
<div class="modal__actions">
<button class="btn btn-danger" onclick="discardMessage()">Discard</button>
<button class="btn btn-secondary" onclick="continueEditing()">Keep editing</button>
<button class="btn btn-primary" onclick="submit()">Send</button>
</div>

View file

@ -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([]);
}

View file

@ -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.") }