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_workspacebefore each scenario - Bridge shim injected via
playwright.config.ts - The HTTP bridge exposes the
get_active_participantsroute. 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 throughwindow.__TAURI_INTERNALS__to simulate presence changes without a live Supabase connection. ThepresenceStorecan also be seeded directly viausePresenceStore.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:
- Initialize a workspace.
- Open any page in the editor.
- Observe the page header area for
[data-testid="presence-indicator"]. - Observe the sidebar for
[data-testid="online-indicator"]. - Call
get_active_participantsvia 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:
- Initialize a workspace.
- Create a page “Watched Page” and navigate to it.
- Inject an agent participant into the
presenceStore(via the test bridge or direct store manipulation) withkind: "agent",display_name: "WriterBot",active_page_slug: "watched-page". - 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:
- Initialize a workspace.
- Navigate to any page.
- Inject a human participant into the
presenceStorewithkind: "human",display_name: "Alice Chen",active_page_slugmatching the current page. - 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:
- Initialize a workspace.
- Navigate to any page.
- Inject an agent participant with
kind: "agent",display_name: "Researcher",active_page_slugmatching the current page. - 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:
- Initialize a workspace.
- Create pages “Page A” and “Page B”.
- Navigate to “Page A”.
- Inject a participant with
active_page_slug: "page-b"into thepresenceStore. - 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:
- Initialize a workspace.
- Navigate to any page.
- Inject 5 participants into the
presenceStore, all withactive_page_slugmatching the current page. - 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:
- Initialize a workspace.
- Navigate to any page.
- Inject exactly 1 participant with
active_page_slugmatching the current page. - Read the
aria-labelattribute 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:
- Initialize a workspace.
- Inject three participants into the
presenceStore: “Alice” on “page-a”, “WriterBot” (agent) on “page-b”, and “Bob” on no page (null slug). SetisOnline: true. - 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:
- Initialize a workspace.
- Inject participants into the
presenceStorebut leaveisOnline: false. - 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:
- Initialize a workspace.
- Create pages “Focus Page” and “Other Page”.
- Inject a participant “Alice” with
active_page_slug: "focus-page"and setisOnline: true. - In the sidebar, click the “Alice” row in
[data-testid="online-indicator"]. - 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:
- Initialize a workspace.
- Inject a participant “Idle Agent” with
active_page_slug: nulland setisOnline: true. - Observe the “Idle Agent” row in
[data-testid="online-indicator"]. - 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:
- Initialize a workspace.
- Navigate to “Target Page”.
- Inject a participant “Leavin’ Larry” on “target-page” via
_addOrUpdateParticipant. SetisOnline: true. - Confirm both
[data-testid="presence-indicator"]on the page and[data-testid="online-indicator"]in the sidebar show “Leavin’ Larry”. - Emit a
participant:leftevent (or call_removeParticipant) for Larry’sparticipant_id. - 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:
- Initialize a workspace.
- Navigate to “Page Alpha”.
- Inject a participant “Mobile User” on “page-alpha”. Set
isOnline: true. - Confirm
[data-testid="presence-indicator"]on “Page Alpha” shows “Mobile User”. - Emit a page-change event for “Mobile User” with
page_slug: "page-beta"(or call_updateParticipantPage). - Observe
[data-testid="presence-indicator"]on “Page Alpha”. - Navigate to “Page Beta” in the editor.
- 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
| Key | Value | Notes |
|---|---|---|
| presence_indicator_testid | presence-indicator | data-testid on the per-page avatar stack component |
| online_indicator_testid | online-indicator | data-testid on the sidebar participant list component |
| max_visible_avatars | 3 | MAX_VISIBLE constant — avatars beyond this show as +N |
| agent_kind | agent | Serialized value of ParticipantKind::Agent in DTOs |
| human_kind | human | Serialized value of ParticipantKind::Human in DTOs |
| agent_tooltip_verb | is reading | Tooltip suffix for agent avatars ("WriterBot is reading") |
| human_tooltip_verb | is editing | Tooltip suffix for human avatars ("Alice is editing") |
| online_dot_color | bg-green-500 | Tailwind class for the online indicator status dot |
| participant_joined_event | participant:joined | Tauri event name for participant join |
| participant_left_event | participant:left | Tauri event name for participant leave |
| participant_page_changed_event | participant:page-changed | Tauri event name for participant page navigation |
| single_label | 1 participant viewing this page | Singular aria-label text |
| plural_label_template | N participants viewing this page | Plural aria-label text (N > 1) |
Notes
- The
PresenceServiceinAppStateisOption<Arc<dyn PresenceService>>. WhenNone(no Supabase configured),get_active_participantsreturnsOk(vec![])without error. All presence UI components handle the empty state gracefully. - The
presenceStoreZustand store is the single source of truth for the frontend. It maintains a module-levelMap<participant_id, ActiveParticipant>for O(1) lookups alongside aparticipantsarray for React rendering. Both must be kept in sync. Tests that callsetState()directly must also call_syncMapFromParticipants()to realign the Map (INK-433). - Presence updates are driven by Tauri events, not polling. The
SuggestionListanalogy is useful: just as it listens foragent:proactive-suggestion, the presence system listens forparticipant:joined,participant:left, andparticipant:page-changed. Tests must emit these events to drive store changes when not using direct store manipulation. - The
PresenceIndicatoruses a stableuseCallbackselector (getParticipantsOnPage) to avoid spurious re-renders on every Zustand state change (INK-523). The selector is re-created only whenpageSlugchanges. ActiveParticipantDtofrom the Rust side serializeskindas the string"human"or"agent". The TypeScript store uses the same literal type union. Tests must use these exact string values.- The
OnlineIndicatorrequires bothisOnline: trueAND at least one participant to render. It renders nothing ifisOnlineisfalse, even if the participant array has entries. This prevents showing stale presence data during reconnection. - The
get_active_participantscommand is async (it may await thePresenceService). It returnsVec<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_participantsSupabase 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). Thelast_seen_attimestamp is used for stale-participant cleanup in the backend.
Was this page helpful?
Thanks for your feedback!