Documentation source
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:
1. **Data Channel** -- Postgres Changes listeners on `entities`, `sessions`, `entity_responses`, and `comments`, all filtered by `entity_id`. Events invalidate the corresponding React Query keys (`["entity", id]`, `["sessions", id]`, `["entity-sessions", id]`, `["entity-responses", id]`, `["comments", id]`). `session_events` is intentionally NOT subscribed here — it carries no `entity_id` column (it's keyed by `session_id`), so per-session transcript updates are handled by `useSessionRealtime` instead.
2. **Presence Channel** -- Tracks which users are viewing this entity. Returns `onlineUsers` (excluding the current user) and `isConnected` status.
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 consume `EntityRealtimeProvider`
- **Chat** (`features/chat/`) -- uses `useRealtimeMessages` and `useTypingIndicator`
- **Comments** (`features/comments/`) -- comment changes are captured by entity realtime listeners
- **Views** (`features/views/`) -- `useViewRealtime` uses the same subscription pattern for view updates