eleven / ocho Public

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>
James Adam 44c1c868564dced21bfe8961d5a680ae126663dd parent f5365cb
6 files changed, +51 −11
  • changelog/2026-07-15-nuanced-app-change-messages.md +4 −0
  • customapps.go +15 −4
  • memberapps.go +6 −2
  • test/ui.test.mjs +8 −0
  • web/static/shell.js +8 −5
  • web/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