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>
4 files changed,
+54
−50
changelog/2026-07-05-reminders-speak-via-dm.md+1 −0test/apps.test.mjs+36 −10web/static/apps.js+7 −4web/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 } }); |
| } |