Live-presence spike: remote selections, mouse cursors, and editor carets#261
Live-presence spike: remote selections, mouse cursors, and editor carets#261Stvad wants to merge 8 commits into
Conversation
Shows other people in a shared workspace in real time, riding Supabase Realtime entirely beside the kernel/PowerSync document path (nothing is persisted): - Realtime Presence carries identity + selected/focused blocks + editor caret; Realtime Broadcast carries the high-frequency mouse cursor. - A single `presenceClient` store owns one channel per workspace, with two notification lanes (presence vs cursor) so a 50ms cursor stream doesn't churn the selection decorators / caret recompute. Wiring uses existing facet seams, no core API changes: - appEffectsFacet -> channel lifecycle + global cursor broadcast - panelMountsFacet -> publishes the active panel's selection/caret - blockShellDecoratorsFacet -> remote-selection rings (stacked per peer) - appMountsFacet -> the mouse-cursor overlay (block-anchored) - codeMirrorExtensionsFacet -> remote editor carets Cursors are anchored to a block id + fractional offset so they land in the same logical place regardless of the viewer's scroll/width. Spike caveats (see presenceClient.ts): defaults on when a hosted Supabase is configured (no-ops in local-only mode); the channel is currently public (harden via Realtime Authorization + RLS on realtime.messages); e2ee workspaces leak block ids + caret offsets (metadata) to the Realtime server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXx9h7irRD4Q1P36xUiite
`view.destroyed` is private in this CodeMirror version (TS2341). Track a local `destroyed` flag set in the plugin's destroy() instead — which also unsubscribes and cancels the rAF, so nothing dispatches post-destroy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXx9h7irRD4Q1P36xUiite
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7c1aae9f76
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const channel = supabase.channel(`presence:${workspaceId}`, { | ||
| config: { presence: { key: this.identity.clientId }, broadcast: { self: false } }, |
There was a problem hiding this comment.
Gate default presence behind private Realtime
When hosted Supabase is configured, presencePlugin is now registered in the static extension list and system toggles default to enabled, so every workspace joins presence:${workspaceId}. This channel is created without config.private: true, which means Realtime authorization/RLS is not applied; because workspace ids are present in app URL hashes, anyone with the public client configuration and a workspace URL can subscribe or broadcast forged cursor/selection/caret state and observe block ids/offsets outside membership checks. Please keep this off by default or make the channel private/RLS-gated before enabling it globally.
Useful? React with 👍 / 👎.
|
|
||
| private handlePointerMove(e: PointerEvent): void { | ||
| const target = e.target as Element | null | ||
| const el = target?.closest?.('[data-block-id]') as HTMLElement | null |
There was a problem hiding this comment.
Anchor cursors only to block shells
[data-block-id] is not unique to rendered block shells; repo-wide it is also used by inline block refs and property rows. When the pointer is over one of those elements, this closest call anchors the cursor to the link/row rectangle, often without a render scope, while the receiver resolves the first matching [data-block-id], so peers can see the cursor jump to an unrelated block or wrong position. Restrict the cursor anchor to the shell element (for example .tm-block[data-block-id] or a dedicated presence-anchor attribute) on both publish and resolve.
Useful? React with 👍 / 👎.
P1 (security): default the plugin OFF (`defaultEnabled: false`). The channel is still public, so an on-by-default presence channel would let anyone with a workspace URL (ids appear in URL hashes) observe or forge presence. Keep it opt-in until it's private + RLS-gated. P2 (correctness): scope cursor anchoring to the block shell (`.tm-block[data-block-id]`) on both publish and resolve. `data-block-id` alone also appears on inline block-refs and property rows, which would anchor the cursor to the wrong rectangle. Also capture a narrowed `client` local in connect() instead of a non-null assertion in the teardown closure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXx9h7irRD4Q1P36xUiite
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d4d5afdc71
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| label.textContent = this.name | ||
| label.style.cssText = | ||
| `position:absolute;left:-1px;top:-1.1em;padding:0 4px;border-radius:4px 4px 4px 0;` + | ||
| `background:${this.color};color:#fff;font-size:10px;line-height:1.3;` + |
There was a problem hiding this comment.
Validate remote color before writing cssText
When a remote presence payload supplies a crafted color value, this interpolation into cssText lets the value terminate the CSS property and append arbitrary declarations to the caret label; any workspace member can alter their Realtime track payload, and the channel is still public when the opt-in feature is enabled. Please derive the color locally from userId or validate it against the expected hsl(...) shape before using it in cssText.
Useful? React with 👍 / 👎.
| if (el.dataset.renderScopeId === cursor.renderScopeId) return el | ||
| } | ||
| } | ||
| return matches[0] |
There was a problem hiding this comment.
Hide scoped cursors when the scope is absent
Even after shell scoping, a cursor that carries a renderScopeId falls back to the first rendered copy of that block when this client does not have the peer's scope. In split panes, embeds, or backlink surfaces the same block can exist in multiple render scopes, so a peer moving in an embed can appear over an unrelated outline copy on another client; return null when a scoped cursor has no matching scope, and only use the fallback for unscoped payloads.
Useful? React with 👍 / 👎.
Address two more Codex P2 findings: - CSS injection: a peer's `color` is untrusted (public channel; any member can alter their `track` payload) and flowed into the caret label's `cssText`. Re-derive colour from `userId` on ingest (presence + cursor) so every consumer uses a locally-computed `hsl(...)`; the wire value is never used. - Cross-scope cursors: when a cursor carries a `renderScopeId` this client doesn't have, return null instead of falling back to the first copy of the block — otherwise a peer in an embed/backlink/other pane shows up over an unrelated copy. Fallback now only applies to unscoped payloads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXx9h7irRD4Q1P36xUiite
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 453f189bc7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Never trust the wire `color`: it flows into `cssText` (caret) and | ||
| // inline styles, so a crafted value could inject CSS. Re-derive it | ||
| // locally from `userId` (only ever a hash seed → always a valid hsl()). | ||
| if (entry) next.set(key, { ...entry, color: colorForUser(entry.userId) }) |
There was a problem hiding this comment.
Validate presence entries before storing
Realtime presence metas are arbitrary JSON from another client, but this stores the entry as a RemotePresence after only re-deriving the color. If a peer tracks a malformed payload such as a missing/non-string userId or selectedBlockIds: null, this path can throw in colorForUser(entry.userId) or later in peer.selectedBlockIds.includes(...), breaking the presence decorators/subscribers for everyone in the channel; this remains possible even after the channel is made private because any workspace member can send malformed presence. Drop malformed entries or normalize the expected fields before adding them to remote.
Useful? React with 👍 / 👎.
| const client = supabase | ||
| const { workspaceId, user } = opts | ||
| this.identity = { | ||
| clientId: getClientId(), |
There was a problem hiding this comment.
getClientId() is persisted for the whole browser installation, so two windows/tabs in the same profile publish the same clientId and use the same Realtime presence key. In that scenario this client filters the shared presence key as self and drops cursor broadcasts with the same clientId, while other peers collapse both tabs into one map entry, so live presence disappears or flickers for normal multi-window use. Generate a session/tab id, or append one to the persisted installation id, for the Realtime key and payload.
Useful? React with 👍 / 👎.
Two more Codex P2 findings: - Validate/normalize every presence meta and cursor broadcast on ingest (untrusted JSON from other clients). `normalizePresence`/`normalizeCursor` coerce each field to its expected shape and drop malformed entries, so a bad peer payload can't throw (colorForUser / selectedBlockIds.includes) and break presence for the whole channel. Local colour-derivation folds in here. - Use a per-tab presence key (`getClientId()` + per-load uuid). The install id alone collapses two tabs/windows of one profile into a single presence and drops their cursors; the nonce makes each tab a distinct peer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXx9h7irRD4Q1P36xUiite
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 09af9d24b2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }) | ||
|
|
||
| const onPointerMove = (e: PointerEvent) => this.cursorThrottled(e) | ||
| const onPointerGone = () => this.broadcastCursor(null) |
There was a problem hiding this comment.
Cancel pending cursor moves before hiding
When the window blurs or the pointer leaves while cursorThrottled has a trailing pointermove queued, this sends the hide packet first but leaves the queued move alive; up to 50 ms later it can rebroadcast the old block-anchored cursor, so peers see a stale cursor remain after the user has left the window until another cursor/presence event clears it. Cancel the throttled pointer handler before broadcasting null in the pointer-gone path.
Useful? React with 👍 / 👎.
When the window blurs or the pointer leaves with a trailing throttled pointermove queued, the hide packet was sent but the queued move could fire ~50ms later and re-broadcast the stale cursor. Cancel the throttle before broadcasting the hide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXx9h7irRD4Q1P36xUiite
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dcce806d0f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const editor = ed && ed.blockId === focusedBlockId | ||
| ? { blockId: ed.blockId, start: ed.start ?? null, end: ed.end ?? ed.start ?? null } | ||
| : null |
There was a problem hiding this comment.
Gate remote carets on edit mode
When the user leaves edit mode while focus stays on the same block (for example blur/Escape sets isEditingProp false without clearing focusedBlockLocation or editorSelection), this still publishes an editor payload because it only checks ed.blockId === focusedBlockId. Since the edit-mode property is ignored, no clearing track is sent, so peers can keep seeing a stale CodeMirror caret/selection until focus moves to another block; include isEditingProp in this selector and publish editor: null when not editing.
Useful? React with 👍 / 👎.
Previously the editor caret was published whenever `editorSelection.blockId` matched the focused block, ignoring `isEditingProp`. Leaving edit mode via Escape/blur (which clears neither focus nor editorSelection) kept publishing the caret, so peers saw a stale CodeMirror caret until focus moved. Gate on the edit flag and publish `editor: null` when not editing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXx9h7irRD4Q1P36xUiite
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aef73ebf58
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const remoteCaretsField = StateField.define<DecorationSet>({ | ||
| create: () => Decoration.none, | ||
| update(decorations, tr) { | ||
| decorations = decorations.map(tr.changes) |
There was a problem hiding this comment.
Rebuild remote carets after document changes
When a peer's presence update arrives before the corresponding block-content sync, the caret offsets are relative to the peer's newer text but buildDecorations clamps them against this editor's older docLength. A later transaction that adopts the synced text only maps the already-clamped DecorationSet, so the original remote offsets are lost and the caret can stay at the old end/wrong position until the peer sends another presence update; rebuild from the latest caretsForBlock(blockId) on doc changes instead of only mapping existing decorations.
Useful? React with 👍 / 👎.
Store raw carets in the StateField and derive decorations via EditorView.decorations.compute(['doc', field], …) so they're rebuilt against the current doc length on every change. The previous approach stored the DecorationSet and only mapped it through changes — a caret that arrived before the block-content sync was clamped to the stale length and the later sync transaction only mapped the clamped set, stranding the caret. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXx9h7irRD4Q1P36xUiite
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 75cc930cf1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| state, | ||
| children, | ||
| }: BlockShellDecoratorProps) { | ||
| const colorKey = useRemoteSelectionColorKey(resolveContext.block.id) |
There was a problem hiding this comment.
Preserve render scope for remote focus
When a peer focuses or edits a block that is also visible in another render scope (split pane, embed, or backlink), the app's own focus state is scoped by focusedBlockLocation.renderScopeId, but presence reduces it to a block id and this hook asks only for resolveContext.block.id. As a result every rendered copy of that block gets the remote focus/edit ring, so other users see activity on the wrong surface; include the render scope in the published presence and filter against this shell's scope before treating focus/editor as occupying the block.
Useful? React with 👍 / 👎.
What this is
An initial spike for live presence on shared workspaces — showing, in real time, where other people are:
The design call: ride Supabase Realtime, beside the document path
Presence is ephemeral awareness state, not document data. The trap is routing it through the persistent path (PowerSync → Supabase
blocks→ e2ee → RLS → compaction), which is poll/throttle-based and built for durable rows — a cursor moving 20×/s would become row-write churn, history pollution, and lag.The standard solution is a separate awareness channel (Yjs
awareness, Liveblocks, etc.). The one already in our stack is Supabase Realtime (@supabase/supabase-jsis present and authenticated) — no new infra, no DB tables, no migration:track) carries identity + selected/focused blocks + editor caret (low frequency, auto-expires on disconnect).cursorevent) carries the mouse cursor (cheap, throttled ~50ms).A single module store (
presenceClient) owns one channel per workspace and has two notification lanes (presence vs cursor) so the 50ms cursor stream never re-runs the selection decorators or the caret recompute.Wiring — existing facet seams only, zero core API changes
appEffectsFacet{repo, workspaceId}) + global pointer-move broadcastpanelMountsFacetuseIsActivePanel)blockShellDecoratorsFacetbox-shadowonshellRef, stacked per peer)appMountsFacet[data-block-id]live each paint)codeMirrorExtensionsFacetBlockEditor)Registered in
staticAppExtensions.tsaspresencePlugin(asystemToggle). New code lives entirely undersrc/plugins/presence/.How to try it
Open the same workspace as two different users (two browsers / a normal + incognito window), each signed into a hosted-Supabase account that's a member of the workspace. Select blocks / move the mouse / edit a block in one window and watch the other.
presence:<workspaceId>; anyone who knows it can join. Hardening = Supabase Realtime Authorization (private channel + an RLS policy onrealtime.messagesgating join onworkspace_members). The user JWT is alreadysetAuth'd so that switch is forward-compatible.encryption_mode='e2ee'workspaces this sends block ids + caret offsets (metadata, not content) to the Realtime server. Either encrypt the presence payload with the workspace key, or gate the feature off for e2ee, before enabling broadly.shellRef.style.boxShadowimperatively (React never sets that prop, so it survives re-renders). A cleaner long-term option is addingstyle?: CSSPropertiestoBlockShellProps(the layout already forwards unknown shell props) — left out to keep the spike contained to the plugin.Verification status — please note
I was unable to run
yarn run checkin the cloud sandbox: the project requires Node ≥24 (only 20–22 available) and, more blocking, the full dependency tree could not finish installing — the agent proxy drops connections mid-install andnode_modulesonly links on a fully-successful pass (it never completed). So typecheck / lint / tests have not run here.What I did instead: built against the actual contracts (read the facet definitions, hooks,
BlockShellDecoratorProps, the CM extension seam,getUIStateBlock/panel wiring) and hand-reviewed for type-correctness — including pre-empting thetrack()/presenceState()interface-vs-index-signature gotcha. Anythingcheckflags I'll fix in follow-up commits.Open questions for you
🤖 Generated with Claude Code
Generated by Claude Code