Dialogue · Notifications — design

Notifications — Phase 1 (+ Web Push, v489)

BUILT — live in the app (v401→v425, 2026-07-14; Web Push v489, 2026-07-18). The rail and its three kinds (build_done · build_failed · room_invite) all ship, verified end-to-end on dev. Since the first cut it also grew: the inbox is an inbox not a log (collapse-key coalescing · revocation · read-on-use), notices split into tasks vs records, one canonical compact form on both the centre row and the toast, the building page reassures you can leave + the Studio tab breathes a cooking dot, and an admin test fixture ("a fake success" / "a fake failure") exercises it all with no LLM spend.

One rail, now four surfaces. Every notification-worthy event writes a durable notices row and pings the already-existing per-user SSE stream. App open → a toast, instantly, on any tab. App closed → it waits in the centre; the ≡ button carries the unread dot next time they look. And since v489 (gap 1) the same events also mirror to Web Push — a lock-screen notification when the browser reaches (Android/desktop Chrome+Firefox; iOS 16.4+ installed). The durable row stays the truth; every mirror is optional, so nothing is lost when the app is closed and mainland devices that can't reach FCM still get the in-app rail.

v489 · Web Push — the deferral is over. Web Push shipped as the rail's third mirror (gap 1 of the Proven-Better-New audit). It rides the SAME hooks — notify() for notices, the message broadcast for chat — driven by one server-side push-spec per kind (the sibling of the client's noticeSpec), never a parallel event path. Standard VAPID (pywebpush; keys in .env, surfaced in the admin Status tab). Per-room coalescing (one evolving notification, WhatsApp). Permission is never prompted on load — a soft-ask card in the centre + a nudge after the first DM/invite + a per-device toggle in the Me sheet; iOS shows an Add-to-Home-Screen hint until installed. The old "Web Push deferred under the China-first framing" line below is now history — for a world audience FCM/APNs just work.
Two rules learned on real devices (v503–v508). 1 · Always show the notification. Subscribing with userVisibleOnly:true is a promise that every push shows something; skipping it is a silent push, and Chrome allows only a small budget before flagging the origin — the live box earned a "Chrome detected possible spam" warning doing exactly that. The only suppression is now the one genuinely redundant case: a message for the room you are focused on this second. Notices, and messages for any other room, always show even with the app open. 2 · Never await clients.openWindow() inside waitUntil. Chrome gates presenting the new window on the notificationclick event settling — so holding the event open on openWindow's own promise is a circular wait, released only by Chrome's internal ~8.5s timeout. That, not any slow code, was why a cold notification launch took ~8s while the same PWA opened from the launcher in ~1.5s. Kick openWindow, race it against a ~1s cap, let the event settle. The diagnostic lesson: tap→navigation measured 8416/8570/8586/8703ms — a constant. Constants are timeouts, not work; we read it as "too slow" for three rounds instead of "something is expiring". Chrome's "Processing notification" spinner is exactly its waitUntil-pending UI. 3 · Nothing network-bound may precede the navigation, and the URL you open should stay the app's start_url (plus ?room= when deep-linking) — extra query params can make an installed PWA launch its start_url and then re-navigate, which reads as the whole app reloading. Where the time actually goes now: our shell boot + render is ~1s and warm launches are ~300ms, so a cold notification tap lands in the same ballpark as a launcher start.
build finishes (or fails · or …) notices table user · kind · link · read_at toast — instant, any tab app open ≡ badge + notification centre always Studio unlocks Confirm live feature hook SSE "notice"

1 · The toast — the instant ping

A human/persona-actor notice rides the lite surface pill (reads as a mini chat row): a small avatar + the byline-voice name + the action + one coral CTA. Auto-dismisses; the notice stays in the centre either way. Fires anywhere in the app — the builder need not be open. Acting on the CTA also marks the notice read.

KKim Jung enbuild failed — nothing to build fromResume
LLisaI'm Lisa — the main voice of Blackpink…Reply
Success → the persona MESSAGES you (v424–v425): on build the server auto-creates a private 1:1 "greenroom" and she SPEAKS FIRST — her live opening bubble, in your language — reused as the notice preview, so the notice IS that message; "Reply" opens the room. Failure → "Resume" reopens the build. Persona actor → a seat-disc; a human inviter → their identicon.

Toast ≠ notification. The app's existing feedback toasts — "This room is full — up to 5 AI personas", "copied", "couldn't reach the curator" — are reactions to what you just did, fired locally: they stay exactly as they are — never stored, never badged, no CTA. A notification is an event that happened asynchronously (later, or elsewhere); it always writes a notices row, and its live mirror reuses the same toast pill — one renderer, plus the optional coral CTA segment. The litmus test: would you still care an hour later? No → toast only. Yes → notice.

2 · The badge — ≡ carries the dot, the Me sheet carries the row

Unread ≥ 1 → an 8px coral dot on the ≡ button (visible from every tab, narrow + wide). Opening ≡ shows the real Me sheet (v345) with one addition: a Notifications row directly under the stats strip — above Admin and Energy — carrying the count. Tap it → the centre (panel 3). Everything else in the sheet is unchanged.

ChatsStudioVibesNotes
The header. The dot is presence, not a number — counts live in the sheet row and the centre. It clears when the centre has been opened.
×
E

enovy

@enovyadmin
here since June 2026
128chats
3.4kturns
41personas
33days
Admin
Admin console
Energy
this fortnight · $1.28 of $4.00
⋯ fortnight · personas · preferences · account ⋯
The Me sheet, faithfully (hero · stats · sections, the real v345 anatomy). The Notifications row is the ONLY addition — a kicker-less box right under the stats, above Admin/Energy. Tap the row to jump to the centre below.

3 · The notification centre

The existing centre (today: Vibes hearts/replies) becomes the home for every kind, newest first. Unread = coral dot + heavier name; tapping a row follows its link and marks it read. Kind-specific CTAs render as pills.

NotificationsMark all read
L
Lisa
I'm Lisa — the main voice of Blackpink. Just came online. What do you want to get into?
2m ago
K
Kim Jung en
build failed — thin corpus
1h ago
W
Walter Morley
liked your post — “the harbour at dusk…”
Yesterday
F
Frank Nolan
was made public by the admin — everyone can seat him now
2d ago
Mixed kinds, one list. Existing Vibes rows keep their look; new kinds join with the same anatomy (avatar · name · action · time · CTAs).

4 · The Confirm screen, mutex-aware — (b)

When your build is already running, Confirm renders disabled with the reason — no tap-into-409. The same SSE event that writes the notice unlocks it in place.

Ready?
Napoleon Bonaparte
b. 1769 · military & statecraft · well documented
your build of Lisa is still running · 6:12 — this button unlocks when she's done
Blocked state. Live elapsed time from the build list; the note names WHICH build holds the lock.
Ready?
Napoleon Bonaparte
b. 1769 · military & statecraft · well documented
Lisa: I'm the main voice of Blackpink…Reply
The unlock moment. Build-done arrives on the stream → toast + the button enables in place. No refresh, no retry.

5 · Building can be left — the "wander off" pair

Users stared at the building page, afraid to lose progress. The build is a server task — leaving, tab-switching, even closing the app never touches it, and the rail above tells them when it lands. So: reassure, and give an ambient "still cooking" signal from anywhere.

building…
a-fake-success · 0:12
Building in the background. You can leave this page — we'll notify you the moment it's ready.
On the building page. A calm notify-when-ready line + a "Go back" exit, both inside one card; the × never cancels. Shown while running, gone once it lands.
Chats Studio VibesNotes
A subtle grey dot breathes on the Studio tab while a build runs — visible from every tab. The ambient "still cooking" signal, cleared when it lands. A breathe, not a spinner: calm, not urgent.
From anywhere. So a user can confidently wander off and still see it's alive.

The kinds

Three kinds ship; the rest join later as one-line notify() inserts — the rail doesn't change. class = record (kept as read history) or task (deleted once acted on).

kindclassrow copyCTA → link
build_donerecordLisa · her seeded hello — a message from the persona (v424)Reply (filled) → her greenroom (the auto-created private 1:1); falls back to See the page if the room couldn't be madelive
build_failedrecordKim Jung en — build failed (reason)Resume build → the parked steplive
room_invitetaskenovy invited you to “战队CW”Open the chat → the roomlive
vibes_heart / replyWalter ♥ liked your post→ the postpre-existing
made_publicrecordFrank Nolan was made public→ persona pagelater
seen_readyrecordyour Seen read is ready→ Notes · Seenlater

Coalescing keys: build:{slug} (done + failed share it — a re-build replaces the stale row; deleting the persona revokes it) · invite:{room_id} (revoked on kick/leave, consumed on entering the room by any door). The admin test fixture "a fake success" / "a fake failure" fires the whole rail with no LLM spend.

Decisions baked into this design

Building can be left (v413; card tidied v422) — the run page carries a calm "Building in the background. You can leave this page — we'll notify you the moment it's ready" line + a "← Go back" exit, both held inside one card (the × never cancels; the build is a server task). From anywhere, the Studio tab breathes a subtle GREY dot while a build runs (ambient "still cooking", cleared when it lands) — so a user can wander off without fear of losing progress. · A built persona messages you (v424–v425) — build_done isn't a system announcement, it's a MESSAGE from the persona: on publish the server auto-creates the owner's private 1:1 "greenroom" seating her, and SHE SPEAKS FIRST — her live opening bubble (the same kickoff any 1:1 "start a chat" runs, in the owner's language), fired off the event loop via asyncio.to_thread (a blocking model call can't run inline in the async completion path) and reused as the notice preview — no extra call. The notice shows that bubble with a filled "Reply" that opens the room. Eager on purpose — if you went to the trouble of building them, you want to meet them. It rides v411's read reconciliation for free (the greenroom IS a chat she's seated in, so opening it clears the badge) and coalesces on the same build:{slug} key. (Fakes seed a templated card line — no LLM.) · One canonical form (v410–v411) — every notice, on BOTH surfaces (the centre row and the toast's lite surface pill), reads as a mini chat row: (small avatar) NAME · time on line 1, the action on line 2, CTA(s) below. The avatar is a human identicon for a person-actor (invite) or a persona seat-disc for a build; the name is in the chat-byline voice (600, muted). New kinds follow this shape. · Meeting her CONSUMES it (v411 → v426)using the persona (opening any chat she's seated in, by any door) answers "she messaged you, come chat", so the build_done is now consumed like the invite — its row + live "Reply" deleted, dropped from the open client via notice_sync. (Originally it only marked the record read + kept it, which left a "Reply" nagging back to a chat you were already in — user-caught.) So build_done is task-like now, though only the chat-open door consumes it (Mark-all-read still just reads it). · A notice lives until its job is done (v405 → v426) — the guiding model: every notice has a durable home elsewhere (the greenroom chat, the joined chat, the Studio build list), so the centre is a task inbox that trends to empty, not a history log. A notice that points somewhere (a chat) is consumed when you go there — room_invite on join, build_done on opening the greenroom. An alert with no destination (build_failed) persists — dimmed once read, a reminder Mark-all-read won't sweep — until you resolve it (Resume relaunches → its notice clears) or delete its persona/chat. New kinds: give it a home, then set "done" = reached-destination vs resolved. · An inbox, not an event log (v404) — every notice carries a collapse key (FCM collapse_key style: invite:{room}, build:{slug}): a repeat replaces its row (re-surfacing unread on top — a re-invite is new information; a successful re-build replaces the stale failure); a counter-event revokes (kicked/left → the invite notice is deleted, silently — no "you were removed" notice, the industry norm); and read-state reconciles — entering a room by any door consumes its invite. Live clients sync rows + badge via notice_sync. · Durable first — the store is the truth; SSE is only the live mirror, so nothing is lost when the app is closed (the honest China-compatible tier). · The dot is presence, not arithmetic — counts live in the sheet + centre. · Toasts are transient — dismissing one loses nothing. · One list, mixed kinds — no per-feature inboxes. · Web Push SHIPPED (v489, gap 1) — the tier-3 mirror landed, bolted onto the same rows exactly as planned: no new event path, just a lock-screen copy of what the rail already emits. The Phase-1 "deferred under the China-first framing" call was reversed once the audience was re-grounded as world users (FCM/APNs just work); the durable-row-first design meant push was a pure add-on, nothing rearchitected.