Skip to content
Documentation GitHub
Agent

Agent & Participant Presence

Agent & Participant Presence

Covers the participant presence system: the PresenceIndicator component (per-page avatar stack), the OnlineIndicator component (sidebar participant list), the presenceStore Zustand store, the get_active_participants Tauri command, and the Tauri event-driven update model (participant:joined, participant:left, participant:page-changed). This spec is P2 because presence is a collaborative awareness feature — it surfaces who (human or agent) is viewing a page and where they are in the workspace. Core writing and page management work without it, but it is central to the multi-participant experience.

The presence system is driven by Supabase Realtime WebSocket events forwarded from the backend. The PresenceService is optional in AppState — when Supabase is not configured or the user is not connected, get_active_participants returns an empty list gracefully. The frontend uses Zustand (presenceStore) as an in-memory participant index updated by Tauri events. The PresenceIndicator shows an avatar stack for the current page; the OnlineIndicator shows a sidebar list of all active participants with their current page slug.

Preconditions

  • HTTP bridge running on port 9990
  • A workspace initialized via initialize_workspace before each scenario
  • Bridge shim injected via playwright.config.ts
  • The HTTP bridge exposes the get_active_participants route. Presence state changes (join, leave, page change) are driven by events emitted from the backend (participant:joined, participant:left, participant:page-changed). Tests may inject these events via the bridge event-emission shim or directly through window.__TAURI_INTERNALS__ to simulate presence changes without a live Supabase connection. The presenceStore can also be seeded directly via usePresenceStore.setState() in tests for UI-only rendering checks.

Scenarios

Seed: seed.spec.ts

1. No presence indicators when no participants are active

In a fresh workspace with no Supabase connection, presence components render nothing.

Steps:

  1. Initialize a workspace.
  2. Open any page in the editor.
  3. Observe the page header area for [data-testid="presence-indicator"].
  4. Observe the sidebar for [data-testid="online-indicator"].
  5. Call get_active_participants via the bridge.

Expected: [data-testid="presence-indicator"] is NOT in the DOM. [data-testid="online-indicator"] is NOT in the DOM. get_active_participants returns an empty array ([]). No errors are logged. The PresenceIndicator and OnlineIndicator components return null when the participant list is empty.

2. Presence indicator appears when an agent participant is on the page

When the presenceStore contains an agent participant viewing the current page, the PresenceIndicator renders an avatar.

Steps:

  1. Initialize a workspace.
  2. Create a page “Watched Page” and navigate to it.
  3. Inject an agent participant into the presenceStore (via the test bridge or direct store manipulation) with kind: "agent", display_name: "WriterBot", active_page_slug: "watched-page".
  4. Observe the [data-testid="presence-indicator"] on the page.

Expected: [data-testid="presence-indicator"] is present in the DOM. The avatar shows an SVG robot icon (not text initials) because the participant kind is "agent". The container has aria-label="1 participant viewing this page" (singular, not “participants”). No initials text like “WR” is shown.

3. Human participant avatar shows initials

A human participant renders as initials derived from the display name (up to 2 characters).

Steps:

  1. Initialize a workspace.
  2. Navigate to any page.
  3. Inject a human participant into the presenceStore with kind: "human", display_name: "Alice Chen", active_page_slug matching the current page.
  4. Observe the avatar in [data-testid="presence-indicator"].

Expected: The avatar shows the text “AC” (initials of “Alice Chen”). No SVG robot icon is shown. The avatar has a title attribute matching "Alice Chen is editing". The presence indicator is visible.

4. Agent avatar tooltip says “is reading” not “is editing”

Agent participants display a different tooltip verb to convey read-only context analysis.

Steps:

  1. Initialize a workspace.
  2. Navigate to any page.
  3. Inject an agent participant with kind: "agent", display_name: "Researcher", active_page_slug matching the current page.
  4. Hover over the agent avatar in the PresenceIndicator.

Expected: The avatar’s title attribute is "Researcher is reading". A human participant on the same page would show "Alice is editing". The two participant kinds have distinct tooltip verbs.

5. Participants on a different page do not appear in the indicator

The PresenceIndicator is scoped to its pageSlug prop — participants on other pages are not shown.

Steps:

  1. Initialize a workspace.
  2. Create pages “Page A” and “Page B”.
  3. Navigate to “Page A”.
  4. Inject a participant with active_page_slug: "page-b" into the presenceStore.
  5. Observe [data-testid="presence-indicator"] on “Page A”.

Expected: [data-testid="presence-indicator"] is NOT in the DOM on “Page A”. The participant is tracked in the store but is only visible when viewing “Page B”. The page-scoped filter (getParticipantsOnPage) excludes cross-page participants.

6. Overflow badge shows when more than 3 participants are on a page

The PresenceIndicator shows at most 3 avatars, then collapses extras into a +N badge.

Steps:

  1. Initialize a workspace.
  2. Navigate to any page.
  3. Inject 5 participants into the presenceStore, all with active_page_slug matching the current page.
  4. Observe the PresenceIndicator.

Expected: Exactly 3 full avatars are visible (the first 3 participants). A +2 overflow badge is displayed after the visible avatars. The badge’s title attribute is "2 more participants". The aria-label on the indicator reads "5 participants viewing this page".

7. Participant count uses singular form for exactly one participant

The aria-label on the indicator uses correct grammatical number.

Steps:

  1. Initialize a workspace.
  2. Navigate to any page.
  3. Inject exactly 1 participant with active_page_slug matching the current page.
  4. Read the aria-label attribute on [data-testid="presence-indicator"].

Expected: The aria-label is "1 participant viewing this page" — NOT "1 participants viewing this page". The singular form is enforced by a ternary check in the component.

8. Online indicator shows all workspace participants in the sidebar

The OnlineIndicator lists every participant regardless of which page they are on.

Steps:

  1. Initialize a workspace.
  2. Inject three participants into the presenceStore: “Alice” on “page-a”, “WriterBot” (agent) on “page-b”, and “Bob” on no page (null slug). Set isOnline: true.
  3. Observe [data-testid="online-indicator"] in the sidebar.

Expected: [data-testid="online-indicator"] is present. The indicator shows a green dot followed by “Online (3)”. All three participants appear as rows. “Alice” and “Bob” show person icons; “WriterBot” shows a robot icon. “Alice” and “WriterBot” rows show their current page slug in a truncated label. “Bob” shows no page slug.

9. Online indicator does not render when isOnline is false

The OnlineIndicator renders nothing when the presence channel is not connected.

Steps:

  1. Initialize a workspace.
  2. Inject participants into the presenceStore but leave isOnline: false.
  3. Observe the sidebar for [data-testid="online-indicator"].

Expected: [data-testid="online-indicator"] is NOT in the DOM, even though participants are in the store. This reflects that the presence channel connection is not active. An offline state with stale presence data must not mislead the user.

10. Clicking a participant row in OnlineIndicator navigates to their page

Each participant row in the OnlineIndicator is clickable when the participant has an active_page_slug.

Steps:

  1. Initialize a workspace.
  2. Create pages “Focus Page” and “Other Page”.
  3. Inject a participant “Alice” with active_page_slug: "focus-page" and set isOnline: true.
  4. In the sidebar, click the “Alice” row in [data-testid="online-indicator"].
  5. Observe the active page in the editor.

Expected: After clicking, the editor navigates to “Focus Page”. The selectPage("focus-page") action from the navigation store is called. The participant row is styled as clickable (not disabled) because active_page_slug is non-null. The “Focus Page” content is shown in the editor.

11. Participant row without a page slug is not clickable

A participant with active_page_slug: null renders a disabled (non-clickable) row.

Steps:

  1. Initialize a workspace.
  2. Inject a participant “Idle Agent” with active_page_slug: null and set isOnline: true.
  3. Observe the “Idle Agent” row in [data-testid="online-indicator"].
  4. Attempt to click the row.

Expected: The row has the disabled attribute or equivalent non-interactive styling (cursor-default, opacity-75). Clicking it does not trigger navigation. The row’s title attribute reads "Idle Agent is online" (not “Navigate to…”). No page navigation occurs.

12. Presence clears when participant leaves

A participant:left event removes the participant from the store and updates both indicators.

Steps:

  1. Initialize a workspace.
  2. Navigate to “Target Page”.
  3. Inject a participant “Leavin’ Larry” on “target-page” via _addOrUpdateParticipant. Set isOnline: true.
  4. Confirm both [data-testid="presence-indicator"] on the page and [data-testid="online-indicator"] in the sidebar show “Leavin’ Larry”.
  5. Emit a participant:left event (or call _removeParticipant) for Larry’s participant_id.
  6. Observe both indicators.

Expected: After step 5, “Leavin’ Larry” is no longer shown in either indicator. [data-testid="presence-indicator"] is gone (no other participants on the page). The OnlineIndicator updates to “Online (0)” or disappears entirely if it requires at least one participant. getParticipantsOnPage("target-page") returns an empty array.

13. Presence updates when participant changes pages

A participant:page-changed event updates the participant’s page in the store without removing and re-adding them.

Steps:

  1. Initialize a workspace.
  2. Navigate to “Page Alpha”.
  3. Inject a participant “Mobile User” on “page-alpha”. Set isOnline: true.
  4. Confirm [data-testid="presence-indicator"] on “Page Alpha” shows “Mobile User”.
  5. Emit a page-change event for “Mobile User” with page_slug: "page-beta" (or call _updateParticipantPage).
  6. Observe [data-testid="presence-indicator"] on “Page Alpha”.
  7. Navigate to “Page Beta” in the editor.
  8. Observe [data-testid="presence-indicator"] on “Page Beta”.

Expected: After step 5, “Mobile User” disappears from the “Page Alpha” presence indicator. After navigating to “Page Beta” (step 7), “Mobile User” appears in the “Page Beta” presence indicator. The OnlineIndicator row for “Mobile User” updates to show “page-beta” as the current page slug. The participant was updated in-place (not removed and re-added), preserving their row order in the sidebar list.

Test Data

KeyValueNotes
presence_indicator_testidpresence-indicatordata-testid on the per-page avatar stack component
online_indicator_testidonline-indicatordata-testid on the sidebar participant list component
max_visible_avatars3MAX_VISIBLE constant — avatars beyond this show as +N
agent_kindagentSerialized value of ParticipantKind::Agent in DTOs
human_kindhumanSerialized value of ParticipantKind::Human in DTOs
agent_tooltip_verbis readingTooltip suffix for agent avatars ("WriterBot is reading")
human_tooltip_verbis editingTooltip suffix for human avatars ("Alice is editing")
online_dot_colorbg-green-500Tailwind class for the online indicator status dot
participant_joined_eventparticipant:joinedTauri event name for participant join
participant_left_eventparticipant:leftTauri event name for participant leave
participant_page_changed_eventparticipant:page-changedTauri event name for participant page navigation
single_label1 participant viewing this pageSingular aria-label text
plural_label_templateN participants viewing this pagePlural aria-label text (N > 1)

Notes

  • The PresenceService in AppState is Option<Arc<dyn PresenceService>>. When None (no Supabase configured), get_active_participants returns Ok(vec![]) without error. All presence UI components handle the empty state gracefully.
  • The presenceStore Zustand store is the single source of truth for the frontend. It maintains a module-level Map<participant_id, ActiveParticipant> for O(1) lookups alongside a participants array for React rendering. Both must be kept in sync. Tests that call setState() directly must also call _syncMapFromParticipants() to realign the Map (INK-433).
  • Presence updates are driven by Tauri events, not polling. The SuggestionList analogy is useful: just as it listens for agent:proactive-suggestion, the presence system listens for participant:joined, participant:left, and participant:page-changed. Tests must emit these events to drive store changes when not using direct store manipulation.
  • The PresenceIndicator uses a stable useCallback selector (getParticipantsOnPage) to avoid spurious re-renders on every Zustand state change (INK-523). The selector is re-created only when pageSlug changes.
  • ActiveParticipantDto from the Rust side serializes kind as the string "human" or "agent". The TypeScript store uses the same literal type union. Tests must use these exact string values.
  • The OnlineIndicator requires both isOnline: true AND at least one participant to render. It renders nothing if isOnline is false, even if the participant array has entries. This prevents showing stale presence data during reconnection.
  • The get_active_participants command is async (it may await the PresenceService). It returns Vec<ActiveParticipantDto>, not a paginated response. In a workspace with many remote participants, the entire list is returned in one call.
  • Presence is currently local-first with Supabase Realtime as the sync layer. In offline mode (no Supabase connection), all presence commands return empty — there is no cached presence state.
  • The workspace_participants Supabase table stores the persistent participant registry, but the in-memory Zustand store drives the UI. These may be out of sync if a participant does not cleanly leave (heartbeat timeout). The last_seen_at timestamp is used for stale-participant cleanup in the backend.

Was this page helpful?