Auto-saving every Additional-Groups pick into favorites made the
list a running audit log of "every group I've ever touched" instead
of a curated set of go-to groups. Since most messages target a
different set of groups (one office one week, three campuses the
next), the auto-save polluted favorites for every user immediately.
Now:
- The Additional Groups picker is per-message only. Changing it no
longer fires /user/groups/add.
- A new "Save selection to favorites" button under the Additional
picker deliberately promotes whatever's currently selected there
into the user's favorites. Explicit action, no surprises.
- On save, the just-promoted groups are moved from the Additional
picker into the Favorites picker so this message's recipient set
survives untouched, but the UI is visually consolidated to one
place (avoids the "same group ticked in two pickers" confusion).
- The favorites VirtualSelect gets its options list rebuilt via
setOptions() so the just-added groups are immediately pickable
without a page reload; the existing selection is captured first
because setOptions can reset it.
- Hint text updated to describe the new intent on both pickers.
CSS: added .fieldActions for a right-aligned button row under a
picker (sits between the picker and its hint).
Backend untouched — /user/groups/{add,remove,list} were already
per-id and the semantics still fit.
Co-authored-by: Cursor <cursoragent@cursor.com>
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>
Selecting a group in "Additional groups" now auto-saves it to the
caller's favorites, and each favorite gets a × affordance for one-click
removal. Favorites still live where they always have —
config.webex.bot[app].authorized[personId].groups — so nothing changes
for existing installations.
Backend
- New POST /CollabCentral/:app/user/groups/add. Body { id }. Validates
the caller is authorized for :app, validates :id resolves to a real
Webex group by looking it up in the cached org-wide group list
(blocks arbitrary strings from being stuffed into config.json),
dedups against the existing favorites array, writes config.json via
saveConfig, and returns the updated array.
- New POST /CollabCentral/:app/user/groups/remove. Body { id }.
Filters that id out of the caller's favorites, writes only when
something actually changed (a remove of an unknown id no-ops instead
of rewriting config.json), and returns the updated array.
- Both endpoints are safe against concurrent writes: index.js is
single-process and node is single-threaded, so read/mutate/write
runs atomically per request.
- Both log a compact audit line (last-8 of personId + group name) so
operators can see who is curating what.
Frontend (sendMessage.html + .js + app.css)
- New "Manage favorites" chip strip renders directly under the
Favorite Groups picker. Each favorite becomes a pill (uses .alias
when set, otherwise .name; long labels truncate with ellipsis).
Clicking × on a pill removes that favorite server-side and updates
the strip locally.
- Additional Groups picker wires a 'change' listener that diffs the
current selection against the previous one and only fires
/user/groups/add for newly-picked ids (never re-fires on the
reselect side of a deselect+reselect, never spams the API with the
full selection on every keystroke).
- Client keeps favoriteGroups mirrored to every API response so the
"already a favorite?" dedup check is a pure in-memory lookup — no
wasted round trips when the user re-picks a group they already
favorited.
- Transient status message ("Added to favorites." / "Removed from
favorites." / error variants) fades under the field label; sticks
around ~4s.
- New shared styles: .fieldLabelRow (label + inline status),
.fieldStatus (with --error variant), .chipStrip, .chip,
.chip__label, .chip__close (with hover/focus states).
Co-authored-by: Cursor <cursoragent@cursor.com>
The nav link race:
- Every page's header used `<a id="jobLink" href=''>` and the real
destination was only assigned later, after /info returned. An empty
href resolves to the current document URL, so clicking "Monitor Jobs"
on sendMessage before /info completed silently reloaded sendMessage.
- Fixed structurally: nav links now use static relative hrefs baked
into the HTML ("./sendMessage.html", "./monitorJobs.html"), so the
destination is correct the moment the DOM parses.
Shared UI:
- New html/css/app.css: design tokens (palette, radius, shadow, font),
sticky compact top header (bot avatar + label on the left, nav pills
in the middle, current user on the right, active-page highlight via
aria-current), card containers, form styling with focus rings,
DataTables theme overrides, status pills, modal, and responsive
breakpoints.
- New html/js/app.js: shared browser bootstrap. Parses appName from
the URL, redirects to OAuth if the id cookie is missing, fetches
/info once, populates the header, applies aria-current to the
active nav link, and invokes a per-page onReady callback with
{ info, appName }. Also exports getCookie, escapeHtml, and formatDate
helpers so each page stops shipping its own copy.
Per-page rewrites:
- sendMessage.html/.js: form now lives in a card, image preview only
shows when a file is attached, EasyMDE + VirtualSelect styled to
match the theme, submit is a primary button, confirmation modal
redesigned. All bootstrap code deleted (delegated to app.js).
- monitorJobs.html/.js: three cards (Running / Scheduled / Completed)
with themed DataTables. Completed table sorts by start time desc,
paginates, and searches; message column truncates HTML previews to
~120 chars. Empty-state text per table. `jobId` and running-row
"view" links go to jobDetail via safe relative URLs.
- jobDetail.html/.js: same shared header + card layout; summary grid,
message preview, and recipient table styled to match the new
palette.
Sanity checks:
- All 43 helper tests still pass.
- Server boots cleanly on port 3001.
- Curl of sendMessage/monitorJobs/jobDetail all return 200 with the
shared header markup.
- /CollabCentral/:app/css/app.css and /CollabCentral/:app/js/app.js
both serve 200 (shared static mount is per-bot as expected).
- No local href in any page is empty; every nav target resolves at
parse time.
- /info and requireBot 404 gate unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
Extends the single-Novi codebase into a multi-bot mass-messenger where
each bot has its own token, avatar, label, and per-user authorization.
- Secrets moved out of config.json: per-bot tokens in gitignored
config/botTokens.json (with enabled flag), service-account OAuth in
gitignored config/token.json (rewritten by refresh cron), integration
and Google keys in .env.
- Single Webex integration handles OAuth for all bots via a per-app
redirect URI derived from OAUTH_CALLBACK_URL_TEMPLATE.
- New requireBot middleware and getBotConfig helper reject requests for
unknown or disabled bots at the /CollabCentral/:app boundary.
- New /info endpoint plus dynamic frontend loading (sendMessage,
monitorJobs) so pages self-describe per bot, including bot avatar
fetched from Webex /people/me at startup.
- Job draft state keyed by cookieId + appName so each bot has its own
building queue; job list/detail endpoints filter by appName so users
only see jobs from bots they are authorized on.
- New jobDetail page for a readable per-job view; completed jobs are
retained for 30 days by the cleanup cron.
- Completion adaptive cards use per-bot avatar and label.
- Miscellaneous fixes: off-by-two in the send loop, removed three dead
send/process variants, added defensive init for jobs.* on load,
dropped the deprecated crypto npm shim, and cleaned up stray logger
labels and typos.
Co-authored-by: Cursor <cursoragent@cursor.com>