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.
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.
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.
enovy
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.
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.
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.
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).
| kind | class | row copy | CTA → link | |
|---|---|---|---|---|
| build_done | record | Lisa · 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 made | live |
| build_failed | record | Kim Jung en — build failed (reason) | Resume build → the parked step | live |
| room_invite | task | enovy invited you to “战队CW” | Open the chat → the room | live |
| vibes_heart / reply | — | Walter ♥ liked your post | → the post | pre-existing |
| made_public | record | Frank Nolan was made public | → persona page | later |
| seen_ready | record | your Seen read is ready | → Notes · Seen | later |
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.