2026 02 25_verse_presence_plan - mark-ik/graphshell GitHub Wiki
Branding update (2026-03-01): Presence is a feature of Verso's local collaboration layer, not of the public Verse network. Presence events are scoped to trusted peers connected via Verso's iroh transport. The implementation plan below is unchanged.
Date: 2026-02-25 Status: Deferred (blocked on Phase 5 done-gate closure) Blocked by: Phase 5.4 delta sync harness + Phase 5.5 access control harness Phase: Post-Phase-5 (Phase 6+) Context: Specifies the minimum presence layer for collaborative sessions โ ghost cursors, remote selection highlights, and follow mode. Presence is a sync UX layer built on top of stable Verso local collaboration semantics, not a substitute for them.
Verse Tier 1 (Phase 5) delivers data sync: intents flow between trusted peers, workspaces converge, and conflicts are resolved. Phase 5 does not deliver presence: there is no representation of remote users in the local graph view during a live session.
This plan introduces Presence as the first post-Phase-5 Verse feature. Presence turns Verse from a data sync layer into a shared work environment by giving each peer a visible avatar, a cursor position, and a selection highlight in the graph pane.
Adoption trigger: This work must not begin until both of the following done gates are met:
- Phase 5.4 done gate:
verse_delta_sync_basicharness passes (create/rename sync between two instances). - Phase 5.5 done gate:
verse_access_controlharness passes (per-workspace, per-peer grant enforcement).
Rationale: Presence requires stable identity (Phase 5.2), live peer connections (Phase 5.3), and reliable workspace membership (Phase 5.5) to be meaningful. Implementing presence on top of an unstable sync layer would mask correctness regressions and conflate presence noise with sync bugs.
The minimum viable presence layer consists of three rendering cues and one interaction mode. Each cue maps directly to a presence event defined in ยง3.
Rendering: A labeled pointer overlay in the graph pane showing where each connected peer's graph-space pointer is hovering.
-
Label: Peer display name (from
TrustedPeer::display_name). -
Color: A per-peer accent color derived deterministically from the peer's
NodeId(HSV hue, fixed saturation/value to remain legible on dark and light graph backgrounds). - Fade-out: If no cursor event is received from a peer for 3 seconds, the ghost cursor fades to 20% opacity. At 10 seconds of inactivity it is hidden entirely.
- Coordinate space: Graph-space (canvas coordinates), not screen-space, so the cursor follows graph pan and zoom correctly.
Rendering: A colored secondary border around nodes and edges that a peer currently has selected.
- Border style: A 2px dashed outer ring in the peer's accent color, offset 3px from the node's own selection border. This distinguishes remote selection from local selection without ambiguity.
- Multiple peers: Stacked concentric rings, one per peer with a selection on that node.
- De-selection: Border is removed when the peer deselects or disconnects.
Rendering: A compact horizontal strip of peer avatar bubbles in the graph pane's top-right corner, visible when at least one peer is connected and presence is active.
- Each bubble shows the peer's initials (first two characters of
display_name) on the peer's accent color background. - A green dot indicator on the bubble shows the peer is actively connected.
- Clicking a bubble enters Follow Mode for that peer (ยง2.4) or opens a tooltip with peer name and last activity timestamp.
Behavior: When the user activates Follow Mode for a peer, the local camera tracks that peer's viewport โ pan and zoom โ in real time.
- Activation: Click a peer avatar bubble in the avatar strip.
- Indicator: The active avatar bubble gains a "following" ring border; the graph pane toolbar shows "Following: Marks-iPhone [โ]".
- Exit: Clicking the โ in the toolbar indicator, clicking the same avatar bubble again, or performing a local pan/zoom gesture exits follow mode immediately.
- Conflict with local input: Any pan/zoom input from the local user immediately exits follow mode. Follow mode is passive โ it never overrides user intent.
- Read-only peers: Follow mode works regardless of whether the peer has read or write access to the workspace.
Presence requires a new lightweight event channel alongside the existing SyncUnit data-sync stream. Presence events are not recorded in the workspace intent log โ they are ephemeral and must not affect workspace state.
/// Ephemeral presence event โ not persisted, not applied to workspace state.
/// Sent over the same iroh QUIC stream as SyncUnit, on a separate logical channel.
enum PresenceEvent {
/// Peer's graph-space cursor position.
/// Sent at most every 50ms (20 Hz cap) to limit bandwidth.
CursorMoved {
peer_id: NodeId,
workspace: String,
graph_x: f32,
graph_y: f32,
timestamp_ms: u64,
},
/// Peer's current selection changed.
SelectionChanged {
peer_id: NodeId,
workspace: String,
selected_node_ids: Vec<NodeId>,
selected_edge_ids: Vec<EdgeId>,
},
/// Peer's camera viewport changed (for follow mode).
ViewportChanged {
peer_id: NodeId,
workspace: String,
center_x: f32,
center_y: f32,
zoom: f32,
},
/// Peer explicitly disconnected from presence (clean exit).
PresenceLeft {
peer_id: NodeId,
workspace: String,
},
}| Event | Rate cap | Trigger |
|---|---|---|
CursorMoved |
20 Hz (50ms minimum interval) | Pointer moved in graph pane |
SelectionChanged |
On change | Selection set changes |
ViewportChanged |
10 Hz (100ms minimum interval) | Camera pan or zoom in graph pane |
PresenceLeft |
On disconnect / tab close | User closes workspace or session ends |
-
CursorMovedandViewportChangedare rate-limited client-side before transmission to avoid flooding the iroh stream. - Presence events are transmitted only to peers with an active session on the same workspace. They are not queued when a peer is offline โ they are dropped.
- Presence events do not affect
VersionVectororSyncLogโ they carry no causal history.
Presence events are multiplexed on a separate QUIC stream within the same iroh connection used for data sync. The SyncWorker opens a presence substream on connection establishment (after authentication in Step 5.3). If the peer's Graphshell version does not support presence, the substream open is a no-op (capability negotiation via stream ID convention).
This is the required default: presence must remain on a separate substream so transient cursor and viewport traffic cannot backpressure semantic sync.
All presence diagnostics follow the verse.presence.* namespace, consistent with verse.sync.* naming in ยง8.4 of the Tier 1 plan.
| Channel | Emitted When |
|---|---|
verse.presence.peer_joined |
Peer sends first presence event for a workspace |
verse.presence.peer_left |
PresenceLeft received or connection dropped mid-session |
verse.presence.cursor_received |
CursorMoved received from peer (debug/verbose only) |
verse.presence.viewport_received |
ViewportChanged received from peer (debug/verbose only) |
verse.presence.follow_mode_entered |
User activates Follow Mode for a peer |
verse.presence.follow_mode_exited |
Follow Mode exited (user gesture or manual exit) |
verse.presence.event_dropped |
Presence event dropped (peer offline, rate-limit, or workspace mismatch) |
cursor_received and viewport_received are verbose-level diagnostics (not shown in default diagnostics pane) to avoid flooding the diagnostics stream at 20 Hz.
Presence is an opt-in feature gated by workspace access and explicit session participation.
Presence events are only accepted from peers who have an active WorkspaceGrant for the relevant workspace (ยง5.5 of the Tier 1 plan). A peer with no grant for workspace W cannot receive or send presence events for W. The SyncWorker rejects presence events for non-granted workspaces with a verse.presence.event_dropped diagnostic.
The Sync Panel includes a per-workspace presence toggle:
โ Share my presence in this workspace
(cursor position, selection, viewport)
When unchecked, the local instance does not emit any PresenceEvent for that workspace. It still receives and renders remote peers' presence (so the user can see others without being seen). A full mutual opt-out requires both sides to disable presence.
Presence events are never written to the workspace intent log. A peer who observes your presence cannot reconstruct your browsing history, undo/redo queue, or workspace state beyond what is already sync-visible. Presence communicates only: current cursor position (ephemeral), current selection (ephemeral, mirrored in sync state anyway), and current viewport (ephemeral, local-only state).
Presence is only active while a peer has the workspace open and the iroh connection is live. Historical or reconstructed presence (e.g., "last seen at this node") is explicitly out of scope for the minimum presence feature and should be treated as a separate analytics/history feature if ever considered.
-
PresenceEventwire format and transmission on QUIC presence substream -
CursorMovedreceive path and ghost cursor rendering -
SelectionChangedreceive path and remote selection border rendering - Peer avatar strip with connection status
-
verse.presence.*diagnostics channels - Per-workspace presence opt-out toggle in Sync Panel
-
ViewportChangedreceive and transmit - Follow Mode activation via avatar bubble
- Follow Mode toolbar indicator and exit gesture
-
verse.presence.follow_mode_entered/verse.presence.follow_mode_exiteddiagnostics
- Presence-aware node tooltip: "Also viewing: Marks-iPhone" when a peer's cursor is over a node
- Presence history: "last active N minutes ago" label on avatar bubble (derived from last received event timestamp โ no new event channel required)
- Cursor trail / path visualization (exploratory โ evaluate after P-1/P-2 land)
Ghost cursors and remote selection borders are rendered as overlays in render/mod.rs (graph pane draw path), after all node/edge geometry is drawn but before the local selection highlight. This ensures local selection always renders on top of remote selection, preserving clear ownership semantics.
Presence overlay data (cursor positions, peer selections, peer viewports) is held in a PresenceState struct owned by the graph view and updated on receipt of PresenceEvents from the SyncWorker. The SyncWorker sends presence events to the render thread via the existing GraphIntent channel using a new GraphIntent::ApplyPresenceEvent variant that does not touch the workspace graph or intent log.
Peer accent colors are derived deterministically:
fn peer_accent_color(node_id: &NodeId) -> egui::Color32 {
// Use first 4 bytes of NodeId as hue seed
let hue_seed = u32::from_le_bytes(node_id.as_bytes()[0..4].try_into().unwrap());
let hue = (hue_seed % 360) as f32 / 360.0;
// Fixed saturation and value for legibility on graph backgrounds
egui::Color32::from(egui::ecolor::Hsva::new(hue, 0.8, 0.9, 1.0))
}This avoids requiring a color negotiation protocol between peers and ensures both sides render the same color for a given peer.
/// Non-persisted presence update delivered to the render/view layer only.
/// Does not enter the workspace intent log or SyncLog.
GraphIntent::ApplyPresenceEvent {
event: PresenceEvent,
}The reducer handles ApplyPresenceEvent by updating AppState::presence (a per-workspace HashMap<NodeId, PeerPresenceState>) without touching the workspace graph.
struct PeerPresenceState {
display_name: String,
accent_color: egui::Color32,
cursor_graph_pos: Option<egui::Pos2>,
selected_node_ids: HashSet<NodeId>,
selected_edge_ids: HashSet<EdgeId>,
viewport: Option<CameraViewport>,
last_event_at: std::time::Instant,
}Presence should follow lightweight realtime-collaboration best practices:
- sender-side rate limiting remains mandatory
- receiver-side rate limiting is also mandatory as a trust boundary (drop and diagnose anything above policy)
- stale presence state should expire automatically without explicit disconnect
- no presence event should ever be persisted, replayed, or included in
SyncUnit - unsupported presence versions should fail closed (ignore the stream, do not attempt fallback parsing)
-
Avatar color collision: Two peers with similar
NodeIdprefix bytes could receive the same hue. Accept with documentation, or add a minimum angular distance check across active peers? (Recommendation: accept in v1; if collisions become common, add local hue separation without changing the deterministic base seed.)
-
design_docs/graphshell_docs/research/2026-02-18_graph_ux_research_report.mdยง15.2 (original ghost cursors concept) -
design_docs/archive_docs/checkpoint_2026-02-24/GRAPHSHELL_P2P_COLLABORATION.md(P2P collaboration vision source) -
design_docs/graphshell_docs/implementation_strategy/2026-02-24_immediate_priorities.mdยง2 item 3 (forgotten concept adoption trigger) -
design_docs/verse_docs/implementation_strategy/2026-02-23_verse_tier1_sync_plan.mdยง5โยง8 (Phase 5 sync foundation this plan depends on)