Sprinter Docs

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}:

TypePurpose
entityPostgres Changes for entity-scoped tables
chatPostgres Changes for chat messages
presence:entityWho is viewing an entity
presence:chatWho is in a chat / typing status
notificationsUser notification delivery
inboxDirect 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:

EventToast messageStyleDuration
sessions UPDATE where status transitions running → completed"Agent work completed"success4 s
entities INSERT/UPDATE/DELETE"This record was updated"info3 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

HookPurpose
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

ComponentPurpose
AvatarStackOverlapping avatar display for online users (max 3 visible, overflow count)
EntityRealtimeProviderContext provider for entity detail pages, wires up useEntityRealtime
EntityRealtimeStatusInline status bar showing online user count and connection indicator
TypingIndicatorAnimated text showing who is typing in a chat

Pure Functions (Exported for Testing)

FunctionPurpose
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.

  • 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

On this page