web+server: nuanced app-list-changed messages (#1034)
apps_changed now carries a kind ("created"/"updated"/"removed") so the
sidebar's reload prompt can say what actually happened instead of
always claiming "New app available" — including for deletions and
member-app edits, which aren't new apps at all.
Fixes #406
Co-authored-by: Claude <noreply@anthropic.com>
6 files changed,
+51
−11
changelog/2026-07-15-nuanced-app-change-messages.md+4 −0customapps.go+15 −4memberapps.go+6 −2test/ui.test.mjs+8 −0web/static/shell.js+8 −5web/static/ui.js+10 −0
diff --git a/changelog/2026-07-15-nuanced-app-change-messages.md b/changelog/2026-07-15-nuanced-app-change-messages.md| new file mode 100644 |
| index 0000000..a49d5a9 |
| --- /dev/null |
| +++ b/changelog/2026-07-15-nuanced-app-change-messages.md |
| @@ -0,0 +1,4 @@ |
| +🤖 The sidebar's app-update prompt is more specific: "A new app is available" |
| +when one's actually new, "App updated" when an existing app changed, and |
| +"Apps changed" for everything else (like an app being removed) — instead of |
| +always saying "New app available" no matter what happened. |
diff --git a/customapps.go b/customapps.go| index 649de71..e217107 100644 |
| --- a/customapps.go |
| +++ b/customapps.go |
| @@ -123,8 +123,10 @@ func (s *Server) serveCustomApp(w http.ResponseWriter, r *http.Request) { |
| // broadcastAppsChanged tells every connected client the installed app set |
| // changed (members:nil → everyone). Modules are dynamic-imported once at boot, |
| // so clients respond by reloading — see the apps_changed handler in shell.js. |
| -func (s *Server) broadcastAppsChanged() { |
| - out, _ := json.Marshal(map[string]any{"type": "apps_changed"}) |
| +// kind is "created" / "updated" / "removed", letting the client pick a more |
| +// specific reload prompt than a blanket "New app available". |
| +func (s *Server) broadcastAppsChanged(kind string) { |
| + out, _ := json.Marshal(map[string]any{"type": "apps_changed", "kind": kind}) |
| s.hub.broadcast <- outbound{data: out} |
| } |
| @@ -192,11 +194,20 @@ func (s *Server) putCustomAppHandler(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "missing source") |
| return |
| } |
| + _, existed, err := s.db.getCustomApp(name) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| if err := s.db.putCustomApp(name, body.Source); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| - s.broadcastAppsChanged() |
| + kind := "created" |
| + if existed { |
| + kind = "updated" |
| + } |
| + s.broadcastAppsChanged(kind) |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "url": "/static/apps-custom/" + name + ".js"}) |
| } |
| @@ -215,6 +226,6 @@ func (s *Server) deleteCustomAppHandler(w http.ResponseWriter, r *http.Request) |
| httpError(w, http.StatusNotFound, "no such app") |
| return |
| } |
| - s.broadcastAppsChanged() |
| + s.broadcastAppsChanged("removed") |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
diff --git a/memberapps.go b/memberapps.go| index 7381d9f..cfd37f5 100644 |
| --- a/memberapps.go |
| +++ b/memberapps.go |
| @@ -154,7 +154,11 @@ func (s *Server) putMemberAppHandler(w http.ResponseWriter, r *http.Request) { |
| } |
| // Same signal the admin console's installs use: connected clients reload |
| // and re-fetch both app tiers. |
| - s.broadcastAppsChanged() |
| + kind := "created" |
| + if exists { |
| + kind = "updated" |
| + } |
| + s.broadcastAppsChanged(kind) |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id}) |
| } |
| @@ -183,7 +187,7 @@ func (s *Server) deleteMemberAppHandler(w http.ResponseWriter, r *http.Request) |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| - s.broadcastAppsChanged() |
| + s.broadcastAppsChanged("removed") |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index 7517106..adfd3af 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -27,6 +27,7 @@ import { |
| normalizeTextSize, |
| TEXT_SIZES, |
| DEFAULT_TEXT_SIZE, |
| + appReloadMessage, |
| } from "../web/static/ui.js"; |
| let failures = 0; |
| @@ -428,5 +429,12 @@ await test("capAllows: a connection's cap is the grant ceiling", () => { |
| assert.equal(capAllows(null, "write"), true); |
| }); |
| +await test("appReloadMessage names what actually changed, not a blanket 'new app'", () => { |
| + assert.equal(appReloadMessage("created"), "A new app is available."); |
| + assert.equal(appReloadMessage("updated"), "App updated."); |
| + assert.equal(appReloadMessage("removed"), "Apps changed."); |
| + assert.equal(appReloadMessage(undefined), "Apps changed."); // older server, no kind sent |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/shell.js b/web/static/shell.js| index 07eaeeb..2c8b05c 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -46,6 +46,7 @@ import { |
| renameNotice, |
| translateMentions, |
| capAllows, |
| + appReloadMessage, |
| } from "/static/ui.js"; |
| import { richTokens, blockTokens, firstLink, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId, relTime } from "/static/format.js"; |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| @@ -3417,19 +3418,21 @@ function toast(msg) { |
| toastTimer = setTimeout(() => t.classList.remove("show"), 1600); |
| } |
| -// A NEW/updated app is available (a bot build, App Studio, or a custom-app |
| -// install). Rather than reload the page out from under someone, surface a |
| +// The app set changed (a bot build, App Studio, a custom-app install/update/ |
| +// removal). Rather than reload the page out from under someone, surface a |
| // subtle, dismissible offer in the sidebar — inline, never a floating toast. |
| // "Later" hides it; the next apps_changed re-offers. Reloading is the reliable |
| // pickup for every tier (member apps also register live, so they already work). |
| -function offerAppReload() { |
| +// `kind` ("created" / "updated" / "removed") picks more specific copy than a |
| +// blanket "New app available" (#406). |
| +function offerAppReload(kind) { |
| const b = document.getElementById("app-update-banner"); |
| if (!b) return; |
| b.textContent = ""; |
| b.classList.remove("hidden"); |
| const msg = document.createElement("div"); |
| msg.className = "update-banner-msg"; |
| - msg.textContent = "A new app is available."; |
| + msg.textContent = appReloadMessage(kind); |
| const row = document.createElement("div"); |
| row.className = "update-banner-row"; |
| const reload = document.createElement("button"); |
| @@ -6476,7 +6479,7 @@ function connectWS() { |
| // under someone — SUBTLY offer a reload for anything that needs a fresh |
| // module load (custom apps, or a code update to an app already loaded). |
| loadMemberAppsNow().then(() => activeId && renderActiveSurfaces()); |
| - if (!hasUnsentDraft()) offerAppReload(); |
| + if (!hasUnsentDraft()) offerAppReload(msg.kind); |
| return; |
| } |
| if (msg.type === "edit") { |
diff --git a/web/static/ui.js b/web/static/ui.js| index f647ac9..29938ff 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -131,6 +131,16 @@ export function memberCountLabel(people, bots, agents = 0) { |
| return parts.join(", "); |
| } |
| +// appReloadMessage picks the reload-banner copy for an apps_changed frame's |
| +// `kind` ("created" / "updated" / "removed", or missing on an older server). |
| +// A blanket "New app available" was confusing for every other kind of change |
| +// (a deletion, a member-app tweak) — this names what actually happened. |
| +export function appReloadMessage(kind) { |
| + if (kind === "created") return "A new app is available."; |
| + if (kind === "updated") return "App updated."; |
| + return "Apps changed."; |
| +} |
| + |
| // ---- presence -------------------------------------------------------------- |
| // Which chats the filter menu's "Show online only" keeps. `others` is roster |
| // info for every member except the viewer: [{ agent, presence }]. People |