eleven / ocho Public

web,remind: reminders speak via the thread, not a floating toast (#202)

A live app event (a fired reminder, a new poll) no longer pops a floating
toast over whatever you're reading — CLAUDE.md bans overlays. It already
announces itself the right way: the event lands in its thread, which floats
up the chat list with an unread dot and preview (bumpThread). A hidden page
still gets the background system notification (push's in-session equivalent).

remind: drop the "Enable reminder alerts" button and the browser-Notification
path entirely — a due reminder arrives as a message from Reminders, so there's
nothing to grant. The offline fallback still fires sid-less reminders, now by
emitting remind.fire WITH its notice so it reads like a server-delivered one.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 847647fb6017aa62186fce2712db588002e56ba6 parent 1633e74
4 files changed, +54 −50
  • changelog/2026-07-05-reminders-speak-via-dm.md +1 −0
  • test/apps.test.mjs +36 −10
  • web/static/apps.js +7 −4
  • web/static/apps/remind.js +10 −36
diff --git a/changelog/2026-07-05-reminders-speak-via-dm.md b/changelog/2026-07-05-reminders-speak-via-dm.md
new file mode 100644
index 0000000..10b6e2c
--- /dev/null
+++ b/changelog/2026-07-05-reminders-speak-via-dm.md
@@ -0,0 +1 @@
+A reminder now arrives as a message from Reminders — the chat floats to the top of your list with an unread dot — instead of a pop-up that covered whatever you were reading. Setting one no longer asks you to "Enable reminder alerts": you'll simply hear from Reminders directly.
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index f702ca5..a6e6b9e 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -581,8 +581,8 @@ await test("a failed schedule still records the reminder, sid-less (client fallb
// ---- spontaneous app → user communication ---------------------------------
-await test("notifyLive toasts an event's own notice (no hook needed)", () => {
- // A hook-less app: the notice riding the event speaks by itself.
+await test("notifyLive: app events never floating-toast; a hidden page gets a system notification", () => {
+ // A hook-less app: the notice riding the event is what speaks.
defineApp({
name: "notix",
title: "Notix",
@@ -594,14 +594,40 @@ await test("notifyLive toasts an event's own notice (no hook needed)", () => {
const toasts = [];
setToast((m) => toasts.push(m));
resetThread("n1");
- notifyLive("n1", { type: "notix.ping", notice: { title: "⏰ Reminder", body: "Call mom" } });
- assert.deepEqual(toasts, ["⏰ Reminder — Call mom"]);
- // An event with no notice (and no notify hook) stays silent.
- notifyLive("n1", { type: "notix.ping" });
- assert.equal(toasts.length, 1);
- // A type no app owns stays silent too.
- notifyLive("n1", { type: "mystery.event", notice: { title: "spoof" } });
- assert.equal(toasts.length, 1);
+
+ // Stand in for the browser: a hidden page with notifications granted.
+ const notes = [];
+ const priorDoc = globalThis.document;
+ const priorNote = globalThis.Notification;
+ globalThis.document = { visibilityState: "hidden" };
+ globalThis.Notification = function (title, opts) {
+ notes.push({ title, body: opts && opts.body });
+ };
+ globalThis.Notification.permission = "granted";
+ try {
+ notifyLive("n1", { type: "notix.ping", notice: { title: "⏰ Reminder", body: "Call mom" } });
+ // No floating toast — a live app event announces itself via its thread (which
+ // floats up the chat list with an unread dot), never a pop-up over your reading.
+ assert.deepEqual(toasts, []);
+ // But a hidden page still gets the background system notification — the
+ // in-session equivalent of the push a fully-closed app would receive.
+ assert.deepEqual(notes, [{ title: "⏰ Reminder", body: "Call mom" }]);
+
+ // A VISIBLE page raises no system notification (you're already here) — and still no toast.
+ globalThis.document.visibilityState = "visible";
+ notifyLive("n1", { type: "notix.ping", notice: { title: "⏰ Reminder", body: "again" } });
+ assert.equal(notes.length, 1);
+ assert.equal(toasts.length, 0);
+
+ // An event with no notice (and no notify hook) stays silent; so does a type no app owns.
+ globalThis.document.visibilityState = "hidden";
+ notifyLive("n1", { type: "notix.ping" });
+ notifyLive("n1", { type: "mystery.event", notice: { title: "spoof" } });
+ assert.equal(notes.length, 1);
+ } finally {
+ globalThis.document = priorDoc;
+ globalThis.Notification = priorNote;
+ }
});
await test("remind: chatMessage — fire is a bot message, setting is an action line", () => {
diff --git a/web/static/apps.js b/web/static/apps.js
index 5e54b21..060bd11 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -826,9 +826,13 @@ export function appUnread(event) {
// shows it when the app is closed, this shows it when the app is open;
// - or the app defines a `notify(event, ctx)` hook returning { title, body }
// (or null for silence) — for wording that depends on current state.
-// The framework shows a toast (in-app) plus a system notification when permitted
-// and the page is hidden. Never called during history refolds: replaying the
-// past must not re-alert it.
+// An app event that arrives while the page is HIDDEN raises a system
+// notification (permission permitting) — the background equivalent of the push
+// a closed app gets. There is deliberately no in-app floating toast: a live app
+// event announces itself where it belongs — the event lands in its thread, which
+// floats up the chat list with an unread dot and preview (see bumpThread). A
+// pop-up that overlays whatever you're reading is exactly the thing CLAUDE.md
+// bans. Never called during history refolds: replaying the past must not re-alert.
export function notifyLive(threadId, event) {
const app = typeOwner.get(event && event.type);
if (!app) return;
@@ -841,7 +845,6 @@ export function notifyLive(threadId, event) {
spec = event.notice;
}
if (!spec || !spec.title) return;
- if (toastFn) toastFn(spec.body ? `${spec.title} — ${spec.body}` : spec.title);
try {
if (
typeof Notification !== "undefined" &&
diff --git a/web/static/apps/remind.js b/web/static/apps/remind.js
index 8b43dd7..d06200c 100644
--- a/web/static/apps/remind.js
+++ b/web/static/apps/remind.js
@@ -9,8 +9,9 @@
// Firing is server-scheduled (ctx.schedule): the due `remind.fire` event is
// encrypted NOW and held by the server, which delivers it into the thread at
// the right moment — so it alerts reliably on every device, closed or open (the
-// service worker shows the event's encrypted `notice`; a live client toasts it).
-// The server learns only (thread, time), never the text — the accepted timing
+// service worker shows the event's encrypted `notice` when closed; a live client
+// surfaces it as a message in the Reminders thread, floated up the chat list with
+// an unread dot). The server learns only (thread, time), never the text — the accepted timing
// trade-off of real alerts. Dismissing an unfired reminder cancels its schedule
// via the sid recorded on remind.add, from any device. If scheduling failed
// (offline), the reminder has no sid and the render ticker fires it best-effort
@@ -241,11 +242,6 @@ export const app = defineApp({
border: none; border-radius: 8px; cursor: var(--ui-cursor, pointer);
}
.remind-hint { font-size: 12px; color: var(--muted); margin: 0 2px 14px; }
-.remind-enable {
- display: block; width: 100%; margin-bottom: 14px; padding: 8px; font: inherit; font-size: 13px;
- color: var(--ink); background: var(--bubble-them); border: 1px solid var(--border);
- border-radius: 8px; cursor: var(--ui-cursor, pointer);
-}
.remind-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.remind-item {
display: flex; align-items: flex-start; gap: 10px;
@@ -303,17 +299,6 @@ export const app = defineApp({
hint.textContent = "e.g. “… in 30m”, “… at 9:00”, or “… | 2026-08-01 14:00”";
pane.appendChild(hint);
- // Offer to turn on browser notifications if they're allowable but not yet
- // granted — without them a due reminder can only mark itself done, silently.
- if (typeof Notification !== "undefined" && Notification.permission === "default") {
- const enable = document.createElement("button");
- enable.type = "button";
- enable.className = "remind-enable";
- enable.textContent = "🔔 Enable reminder alerts";
- enable.onclick = () => Notification.requestPermission().then(() => enable.remove());
- pane.appendChild(enable);
- }
-
if (!items.length) {
const empty = document.createElement("div");
empty.className = "remind-empty";
@@ -392,23 +377,12 @@ export const app = defineApp({
},
});
-// remind.fire ids we've already surfaced locally via the offline-fallback fire()
-// below. Device-local (module scope) on purpose: it discriminates the firing
-// device from the user's other devices, which a shared-uid check cannot.
-const firedLocally = new Set();
-
-// fire is the offline fallback: a best-effort local notification for a due
-// reminder that has no server schedule, recording that it fired (idempotent —
-// reduce flags it, so reopening won't alert again).
+// fire is the offline fallback: when a reminder's server schedule failed (no
+// sid), the render ticker fires it best-effort while this pane is on screen. It
+// emits remind.fire WITH the notice, so an offline reminder reads exactly like a
+// server-delivered one — the bot's line in the Reminders chat, the sidebar
+// preview — just surfaced later, by whichever of your devices had the pane open.
+// Idempotent: the fold flags it, so reopening won't re-fire.
function fire(item, ctx) {
- try {
- if (typeof Notification !== "undefined" && Notification.permission === "granted") {
- new Notification("⏰ Reminder", { body: item.text, tag: "lchat-remind-" + item.id });
- // We just alerted locally; mark it so notify() swallows the incoming echo
- // (only set when we actually showed a Notification — otherwise the echo's
- // toast is this device's only alert and must not be suppressed).
- firedLocally.add(item.id);
- }
- } catch {}
- ctx.emit({ type: "remind.fire", id: item.id });
+ ctx.emit({ type: "remind.fire", id: item.id, notice: { title: "⏰ Reminder", body: item.text } });
}