Realtime
Supabase Realtime subscriptions for live data updates, entity presence tracking, chat message delivery, and typing indicators.
Realtime
The Realtime module provides live collaboration features across the platform using Supabase Realtime channels. It powers three capabilities: automatic data refresh when database rows change, presence tracking to show who is viewing an entity, and instant chat message delivery.
Overview
The module lives in features/realtime/ and exports composable hooks that other features consume. Entity detail pages use useEntityRealtime() to get both live data updates and presence. Chat uses useRealtimeMessages() for instant message delivery and useTypingIndicator() for typing awareness. The system is built on two Supabase Realtime primitives: Postgres Changes (database event streaming) and Presence (ephemeral user state).
Key Concepts
Channel Types -- Six channel types organize realtime traffic. Each channel name follows the pattern tenant:{tenantId}:{type}:{resourceId}:
| Type | Purpose |
|---|---|
entity | Postgres Changes for entity-scoped tables |
chat | Postgres Changes for chat messages |
presence:entity | Who is viewing an entity |
presence:chat | Who is in a chat / typing status |
notifications | User notification delivery |
inbox | Direct message inbox |
Channel Name Builder -- buildChannelName(type, tenantId, resourceId) constructs the standardized channel name string. All realtime consumers use this function to ensure consistent naming.
PresenceUser -- The shared type for presence state: userId, displayName, avatarUrl, and lastSeen timestamp.
Multi-Tab Deduplication -- When a user has multiple browser tabs open, each tab tracks presence independently. The dedupePresenceEntries() function merges multiple presence rows per user into a single record, preferring the most recent lastSeen and preserving typing status across tabs.
How It Works
Foundational Hook: useRealtimeSubscription
The lowest-level hook creates a single Supabase Realtime channel with multiple Postgres Changes listeners. Each listener watches a specific table (optionally filtered) for INSERT, UPDATE, DELETE, or all events. When any event fires, the hook invalidates specified React Query keys so the UI re-fetches fresh data.
useRealtimeSubscription(channelName, listeners, queryKeys, options?)This hook is never used directly by page components -- it is composed into higher-level hooks.
Entity Realtime
useEntityRealtime() is a composite hook that sets up everything an entity detail page needs:
-
Data Channel -- Postgres Changes listeners on
entities,sessions,entity_responses, andcomments, all filtered byentity_id. Events invalidate the corresponding React Query keys (["entity", id],["sessions", id],["entity-sessions", id],["entity-responses", id],["comments", id]).session_eventsis intentionally NOT subscribed here — it carries noentity_idcolumn (it's keyed bysession_id), so per-session transcript updates are handled byuseSessionRealtimeinstead. -
Presence Channel -- Tracks which users are viewing this entity. Returns
onlineUsers(excluding the current user) andisConnectedstatus.
The EntityRealtimeProvider wraps entity detail pages and provides context to child components. It shows contextual toasts for remote changes — suppressed for 3 seconds after any local save to avoid self-notifications:
| Event | Toast message | Style | Duration |
|---|---|---|---|
sessions UPDATE where status transitions running → completed | "Agent work completed" | success | 4 s |
entities INSERT/UPDATE/DELETE | "This record was updated" | info | 3 s |
Each toast carries a stable id (session-completed-{entityId} / entity-update-{entityId}) so rapid successive events do not stack multiple identical notifications in the UI. The session-completed toast also requires the payload to contain both new.status and old.status (REPLICA IDENTITY FULL is required on sessions for the old row; when it's absent the check degrades gracefully to "fire on UPDATE with new.status === completed", which still correctly fires once per completion).
Chat Message Delivery
useRealtimeMessages() listens for INSERT events on the messages table filtered by chat_id. New messages are appended directly to the React Query cache for instant display, with deduplication by message ID to handle race conditions between the cache write and the query refetch.
Typing Indicators
useTypingIndicator() uses a Presence channel scoped to a chat. When a user starts typing, it calls channel.track({ typing: true }). The hook resolves all typing users (excluding the current user) and provides a setTyping(boolean) callback. The TypingIndicator component renders formatted text like "Alice is typing..." or "Alice and 2 others are typing...".
Presence Tracking
usePresence() is the core presence hook. It subscribes to a Presence channel, tracks the current user, and returns all other online users. The presence state is flattened from Supabase's nested format, deduplicated across tabs, sorted alphabetically, and filtered to exclude the current user.
API Reference
Hooks
| Hook | Purpose |
|---|---|
useRealtimeSubscription(channel, listeners, queryKeys, options?) | Foundation: multi-listener Postgres Changes with React Query invalidation |
usePresence(channelName, currentUser) | Presence tracking with multi-tab dedup |
useRealtimeMessages(channel, chatId, enabled?, options?) | Chat message INSERT streaming with cache append |
useEntityRealtime(entityId, tenantId, currentUser, options?) | Composite: data changes + presence for entity detail pages |
useTypingIndicator(chatId, tenantId, currentUser) | Chat typing status via Presence |
Components
| Component | Purpose |
|---|---|
AvatarStack | Overlapping avatar display for online users (max 3 visible, overflow count) |
EntityRealtimeProvider | Context provider for entity detail pages, wires up useEntityRealtime |
EntityRealtimeStatus | Inline status bar showing online user count and connection indicator |
TypingIndicator | Animated text showing who is typing in a chat |
Pure Functions (Exported for Testing)
| Function | Purpose |
|---|---|
buildChannelName(type, tenantId, resourceId) | Build standardized channel name |
flattenPresenceState(state) | Flatten Supabase presence state into flat array |
dedupePresenceEntries(presences) | Merge multi-tab presence into one record per user |
resolveOnlineUsers(state, currentUserId) | Full pipeline: flatten, dedupe, exclude self |
resolveTypingUsers(state, currentUserId) | Filter to users with typing: true, exclude self |
formatTypingText(users) | Format typing indicator display text |
_appendMessage(existing, newMessage, appendIfMissing) | Cache-safe message append with dedup |
_buildEntityListeners(entityId) | Build listener config for entity realtime |
_buildEntityChannelNames(tenantId, entityId) | Build data + presence channel names |
For Agents
Agents do not directly interact with realtime channels. However, when agents submit responses, promote entity values, write session events, or update entities through explicit editor/API paths, those changes trigger Postgres Changes events that the realtime system picks up and pushes to connected browsers.
This means agent actions are reflected in the UI in real time without any polling.
Design Decisions
Agent-output toasts are distinguished from entity-update toasts. Before this change, any remote table change on entities fired a generic "This record was updated" toast, whether it came from a human edit or background agent work. Users could not tell whether they needed to review new agent output or just acknowledge a collaborator's change. Separating sessions, session_events, and entity_responses events into dedicated toasts gives users actionable context.
Toast IDs prevent notification stacking. Agent work can complete multiple fields in rapid succession, each triggering session and response events. Without stable IDs, these would stack into a tower of identical toasts. Entity- and session-scoped toast IDs keep per-run updates distinct while shared progress IDs collapse noisy updates into a single notification.
Related Modules
- Entity System (
features/entities/) -- entity detail pages consumeEntityRealtimeProvider - Chat (
features/chat/) -- usesuseRealtimeMessagesanduseTypingIndicator - Comments (
features/comments/) -- comment changes are captured by entity realtime listeners - Views (
features/views/) --useViewRealtimeuses the same subscription pattern for view updates