Skip to content

feat(notifications): mentions, inbox, and table watches - #1243

Open
joepio wants to merge 55 commits into
developfrom
cursor/notifications-design-0fcd
Open

feat(notifications): mentions, inbox, and table watches#1243
joepio wants to merge 55 commits into
developfrom
cursor/notifications-design-0fcd

Conversation

@joepio

@joepio joepio commented Aug 1, 2026

Copy link
Copy Markdown
Member

#77 #1157

#77 #1157

Adds in-app notifications for @mentions, watched tables/collections, direct messages, and access requests.

When someone mentions you, a watched query changes, a collaborator DMs you, or someone asks for access, the event lands as a NotificationItem on your private drive — not the current workspace. The App-menu bell opens /app/notifications. Read and dismissed state syncs with that drive.

This is the product slice of #77 (mentions, subscriptions, inbox — not feeds) and the mention-picker work in #1157 (search agents outside the current drive).

Rebased onto latest develop (private-drive rename, planning index restructure, spaUrl, and related e2e helpers).

Mentions

  • TipTap and chat @ write a mentions edge on the authored resource (actor-side, not inbox spam).
  • NotificationEngine reverse-queries those edges and materializes inbox rows for the mentioned agent.
  • The mention search can pick agents that are not in the current drive.

Watches

  • Watch toggle on tables and collections.
  • Settings lists the agent's watches (enable / mute / remove).
  • Incoming UPDATE/DESTROY is matched with the same constraintMatches the Collection view already uses. Membership and content kinds are both supported.

Direct messages and access requests

  • DirectMessage and AccessRequest classes in the notifications ontology.
  • Inbox actions: send a message to a drive collaborator, grant an access request.

Where it lives

Inbox, watches, preferences, and DevicePushTokens sit under {privateDrive}/notifications/ (getOrCreateNotificationsFolder).

/app/dev-drive now matches onboarding: a private drive (personal: true) first, then a Dev workspace (personal: false). The agent secret's initialDrive is the private drive so inbox writes and queries agree, while the UI lands on Dev.

Accepting a drive invite still makes that drive the session drive. Accepting a child invite (chatroom, document) keeps the session on the invitee's {name}'s Drive — the grant is the child, not the parent.

Delivery

  • In-app center, unread badge, and toasts.
  • Desktop / web OS notifications (tauri-plugin-notification + Notification API).
  • Hub DevicePushToken lookup stub (wake-to-sync; payload still comes from the store).

Tests

  • @tomic/lib vitest covering engine, watches, and helpers.
  • Playwright notifications.spec.ts for inbox UI, mention/watch paths, and engine materialization.
  • Smoke e2e.spec.ts chatroom invite asserts the sidebar stays on {name}'s Drive.

Related Issues

#77 #1157

Checklist

  • Add changelog entry linking to issue, describe API changes
  • Add or update tests if needed
  • Update docs if needed
Open in Web Open in Cursor 

@cursor cursor Bot changed the title docs(planning): notifications design — mentions + watch subscriptions feat(notifications): mentions, inbox, and table watches Aug 1, 2026
@gitguardian

gitguardian Bot commented Aug 14, 2026

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@cursor
cursor Bot force-pushed the cursor/notifications-design-0fcd branch 2 times, most recently from f0c9c34 to 7490b98 Compare August 19, 2026 15:36
cursoragent and others added 26 commits August 28, 2026 07:33
…ions

Propose actor-side mention edges, personal NotificationItems, client-side
watch matching for collections/tables, and a phased delivery path from
in-app center through Tauri/OS notifications to later push-as-wake.

Co-authored-by: Joep Meindertsma <joep@ontola.io>
Lock personal-drive sync for read/dismissed, and document the local
notification vs APNs/FCM split for Tauri mobile (wake-then-sync payload).

Co-authored-by: Joep Meindertsma <joep@ontola.io>
Lock App menu order (User Settings → Notifications → Settings) and split
inbox route from App Settings preference section.

Co-authored-by: Joep Meindertsma <joep@ontola.io>
Add the notifications ontology, write actor-side `mentions` from TipTap
and chat, materialize personal NotificationItems via NotificationEngine,
and ship the sidebar inbox plus a Watch toggle on tables.

Co-authored-by: Joep Meindertsma <joep@ontola.io>
Add Playwright specs for the notifications sidebar/inbox, unread badge,
mark-all-read, and table Watch toggle. Fix WatchToggle busy-state handling
to avoid bare try/finally for React Compiler.

Co-authored-by: Joep Meindertsma <joep@ontola.io>
Subscribe inbox rows to LocalChange via useValue, notify the store after
mark/dismiss saves, and fall back to direct store writes if the engine
is still starting. Parent the notifications ontology under
https://atomicdata.dev/ for rights walks. All four notifications
Playwright specs are green; populate unit asserts the ontology seeds.

Co-authored-by: Joep Meindertsma <joep@ontola.io>
Show Web Notification API / tauri-plugin-notification banners when the
tab or window is unfocused; toast when focused. Permission is requested
from Watch enable and App Settings, never on cold start. Mark-read and
dismiss cancel matching OS notifications. Wire the Tauri plugin +
capabilities; document remaining Phase 5 push work.

Co-authored-by: Joep Meindertsma <joep@ontola.io>
- WatchToggle on Collection views; Settings watches list (mute/remove)
- E2E: watch + simulated other-agent child materializes NotificationItem
- DevicePushToken ontology + registerDevicePushToken helper
- Hub push_wake helpers (wake-only payload) + commit_monitor hook note
- Cold-start push tap queue; flushPendingWatches for tests

Co-authored-by: Joep Meindertsma <joep@ontola.io>
- data-watching / mark-all-read / notifications-empty test ids
- flushPendingWatches after brief wait for async ResourceUpdated
- Avoid brittle lingui-scrambled accessible names in assertions

Co-authored-by: Joep Meindertsma <joep@ontola.io>
… tap wire

- E2E: mention ResourceUpdated (other actor) materializes inbox item
- E2E: mark read on A clears badge on B (same agent, two contexts)
- Wire pushWakeTap → navigate in NotificationOsPresenter
- useDevicePushRegistration on launch; suppress-if-read helpers

Co-authored-by: Joep Meindertsma <joep@ontola.io>
- Wait for engine; in-app nav after mention materialization
- Second-device sign-in without Continue; wait for agent then open inbox

Co-authored-by: Joep Meindertsma <joep@ontola.io>
- NotificationEngine re-runs mention backlog on DriveChanged
- E2E: A invites B, mentions B while B's engine stopped, B reconciles
- Closes reverse-query + two-agent invite gaps in planning/coverage

Co-authored-by: Joep Meindertsma <joep@ontola.io>
Top-bar Share on a freshly UI-created document can leave useCanWrite stuck
false so Create Invite never appears. Use the same drive context-menu share
path as the authorization invite e2e instead.

Co-authored-by: Joep Meindertsma <joep@ontola.io>
Fix Share Create Invite stuck read-only on new DID docs by resetting
useCanWrite optimistically on subject/newness and re-checking when
resource.new flips after genesis.

Phase 5: handlePushWake/processPushWake (fetch → reconcile →
suppress-if-read), receive queue wired in NotificationOsPresenter, and
commit_monitor mention → wake stub (provider send still TODO).

Co-authored-by: Joep Meindertsma <joep@ontola.io>
Pick tauri-plugin-push-notifications for Phase 5 (documented; Cargo gated
until Firebase/APNs project files exist). Add tauriPushBridge to drain
local notification cold-start taps and optionally fetch a device token.
Hub enqueue_push_wakes looks up DevicePushToken rows per wake agent.

Co-authored-by: Joep Meindertsma <joep@ontola.io>
Add PushSender trait with LoggingPushSender default (ready for FCM/APNs),
watch subscription wake matching (target/parent, mute/enabled, owner),
and route commit_monitor through wakes_for_committed_resource.

Co-authored-by: Joep Meindertsma <joep@ontola.io>
Add EnvPushSender (ATOMIC_FCM_* / ATOMIC_APNS_* bearers) installed at
serve boot, falling back to log-only without secrets. Gate the Tauri
push plugin behind --features mobile-push and document enablement in
planning/notifications.md.

Co-authored-by: Joep Meindertsma <joep@ontola.io>
Co-authored-by: Joep Meindertsma <joep@ontola.io>
Add DirectMessage and AccessRequest ontology classes, actor-side
mentions so the existing engine materializes inbox items, a Send
message dialog for drive collaborators, Request access on Share and
Unauthorized, and Grant from the inbox / request page.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
The notifications page queried WASM once on mount. A second browser
signing in as the same agent could miss NotificationItems that arrived
via drive sync after that query, so mark-read never had a row to sync.

Scan the in-memory store (and re-query on ResourceUpdated / sync
status), wait for the seeded item to drain before opening device B,
and keep the index query as a backlog supplement.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
pendingDirtyCount can stay non-zero for unrelated in-flight saves.
Wait until the NotificationItem is fetchable over HTTP so device B
has something to sync.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
The App menu label is often the agent name, so waiting for a
User Settings link races sign-in. Agent subject is enough before
opening the inbox.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Drive sync can finish before nested NotificationItems are in the
parent index, so a second device's WASM collection stays empty.
Ask /query for isA=NotificationItem on the personal drive and
hydrate those subjects into the store.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Device B's drive sync can omit nested NotificationItems. Fetch the
item A already posted so the inbox and mark-read path can run.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Restricting the listener to NotificationItem classes missed items
whose isA had not materialized yet at notify time. Debounced rescan
of the in-memory store on every ResourceUpdated.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
bootstrap() no-ops on an already-seeded store, so --initialize never
added NotificationItem and commits 500'd while fetching the class from
atomicdata.dev. Re-run populate on initialize when SHORTNAME exists.

Also wait for the seeded inbox item to drain before opening device B.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
cursoragent and others added 23 commits August 28, 2026 07:35
/app/dev-drive now provisions Personal + Dev drive. Under suite-wide
load that setup ate the old 60s budget before the spec body ran.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Callers like the user-drives spec open the helper from User Settings,
which has no resource subject. Treat the previous subject as optional.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Keep the /app/dev-drive secret's initialDrive on Personal so inbox writes
and queries use the same drive. Remount the notification engine when the
Agent resource gains personalDrive (invite persist), and recover from a
failed first start instead of leaving __notificationEngine unset.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
reloadWatches no longer replaces the watch map with an empty collection
index after a just-saved subscription. The unread badge always hits
server /query, and invite e2e opens /app/invite on FRONTEND_URL so the
invitee uses the same bundle as the rest of the suite.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Re-fetch notification items over the websocket so device B sees mark-read
commits. After invite persist, rebuild the Agent with Personal as
initialDrive so the engine remounts on the private inbox drive.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Always HTTP-refetch inbox rows so mark-read is not stuck on a cached
unread copy. The second context now signs in through signIn() and waits
for Connected before opening the inbox.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
visibleNotificationItems no longer skips `new` rows, so a watch or
mention upsert appears in the inbox before the collection index
catches up.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
cargo fmt --check and pnpm lint (oxlint + oxfmt) are the first CI
gates. Format the notifications/push Rust and TS that were blocking
the pipeline before e2e could run.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
CI fail-fast passed rustfmt/oxlint; flutter analyze then exited 1 on
unused_import / unnecessary_import in the push notification files.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
The bin re-declares modules instead of using the lib. push_provider
imports crate::push_credentials, which compiled in the lib but not
in atomic-server's binary until the module is listed in bin.rs.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
CI linked a stale rustc incremental binary (old simple.rs line numbers,
10 tests instead of 12) and the suite shares a process-global OnceLock
DB. Touch sources so cargo rebuilds, run --test-threads=1, and pin
active_drive from setup's returned subject.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Touching only the bridge sources rebuilt simple.rs against a cached
atomic_lib rlib that predated DEVICE_PUSH_* url constants (E0425).
Clean both packages so cargo rebuilds lib + the bridge together.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Notifications ontology adds access-request (and others) that sort
before agent. Assert nested members are sorted by shortname and that
Agent is still in the collection.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Dev-drive agents are named Dev User, so signIn's 10s wait for a
User Settings row never resolved (and swallowed the timeout). Second
contexts then raced presence, meetings, and cold-load. Wait for
store.getAgent() instead.

Open the inbox via a scrolled, force-clicked sidebar-notifications
testid — the App menu sits below two drives. Ignore NotificationEngine
isA/mentions /query frames in the refresh-storm regression.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Dev-drive secrets pin initialDrive to Personal. A second context that
only opens ?subject=<Dev drive> still has Personal as store.getDrive(),
so presence, meetings, and cold-load children miss the workspace.
Click Set as current drive after that navigation.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
The Drive page's Set as current drive control often appears after the
previous 5s visibility check, so second contexts stayed on Personal.
Call store.setDrive and wait until the session drive matches.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Same-agent extra contexts sign in with Personal as initialDrive, then
open a workspace canvas/document. Live WS stays on Personal unless we
setDrive from the resource's stamped drive property.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Share getOrCreateFolderByLocalId with Drafts/Forks, list inbox subjects
once for the route and badge, match watches via Collection.constraintMatches,
and drive e2e folder/drive setup through production helpers.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
createdBy lives on server.properties, not core. Passing undefined into
Resource.set crashed Loro in the engine-path notification specs.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
e2e format-check failed on an 80-col import. InvitePage no longer assigns
agent.initialDrive on the value from useSettings (react/immutability);
new-agent invites rebuild from the secret instead.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Production Vite builds look up chrome text by catalog id. Mentions, watches,
DMs, and access-request copy was never extracted, so CI rendered empty
headings (the inbox h1 had no accessible name).

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Use testids for the notifications heading and send-message dialog so CI
does not depend on translated accessible names. Fall back to the personal
drive when the invite host is unreadable, and accept either Dev drive or
the invitee's home title after accept.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Develop renamed the user-facing personal drive to private drive. Keep the
two-drive /app/dev-drive split and update comments/docs to match.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
@cursor
cursor Bot force-pushed the cursor/notifications-design-0fcd branch from 1dfbdd4 to ba2d7c2 Compare August 28, 2026 07:41
joepio added a commit that referenced this pull request Aug 28, 2026
Settles vocabulary and shape for work that spans three existing docs. Decided in
conversation, so written down before it has to be decided again.

Users see two words: templates and integrations. "App" is not a category —
thirteen of fourteen candidate mini-apps shipped as pure configuration, so an
app is how a few templates are built, not something anyone browses for.
Offering both as siblings is the mistake already made in the assistant's tools,
where two curated sources with no rule for choosing produced a hand-written CRM
instead of the CRM template. Automations get a third word when the first one
ships, not before.

The catalogue is browsed by shape under New and by brand under Import, because
nobody thinks "I want a table template" — they think "I use Notion". A tested
catalogue starts at about three entries, which reads as supporting nothing, so
the search that finds nothing offers to have one written. That is what makes a
small catalogue honest rather than embarrassing.

For automations: no node editor, because the verdict preview already answers the
question a node editor exists to answer, and better. The trigger stays
declarative and already is. Outputs split into proposed and immediate, and there
is a hole — ctx.http fires during the run that produces the verdict, so an
outbound call escapes the review it appears to be under.

Also records that #1243 gives the notification contract and not its producer:
zero server files means an automation's "notify me" needs a second, server-side
writer of the same resource.
joepio added a commit that referenced this pull request Aug 28, 2026
Both the badge tooltip and the share warning listed notifications among
what the private drive holds. It does not: notifications are #1243, still
unmerged. What is actually there is the saved-drives list (seeded in
`ensurePersonalDrive`), favourites, and AI chats.

Warning someone about data that does not exist teaches them to discount
the warning, which is the one thing this copy cannot afford — it is the
last thing between a private drive and being handed to a colleague.
cursoragent and others added 3 commits August 28, 2026 17:42
Chatroom invites grant the child, not the parent drive. Activating that
host DID left the invitee sidebar showing a truncated subject instead of
Dev drive or "{name}'s Drive", which failed the smoke chatroom spec.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Chatroom invites grant the destination, not the parent. Activating the
host after a name-check still raced: adoptDriveFromDeepLink could
overwrite the session with the unread parent (Unauthorized / truncated
DID). Only land on the host when the destination is itself a Drive, and
do not clobber a did: session drive after the deep-link await.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
Extract inviteSessionDrive so a child invite cannot land on an unread
parent. Unit tests pin drive-level vs child-resource behavior.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
@joepio
joepio marked this pull request as ready for review August 28, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants