Skip to content

Live-presence spike: remote selections, mouse cursors, and editor carets#261

Open
Stvad wants to merge 8 commits into
masterfrom
claude/nifty-fermat-0u7n6g
Open

Live-presence spike: remote selections, mouse cursors, and editor carets#261
Stvad wants to merge 8 commits into
masterfrom
claude/nifty-fermat-0u7n6g

Conversation

@Stvad

@Stvad Stvad commented Jun 25, 2026

Copy link
Copy Markdown
Owner

What this is

An initial spike for live presence on shared workspaces — showing, in real time, where other people are:

  1. Selected / focused blocks — a coloured ring around any block a peer has selected, focused, or is editing (stacks per peer).
  2. Mouse cursors — each peer's pointer, with a name label, anchored to a block + fractional offset so it lands in the same logical spot regardless of the viewer's scroll position or window width.
  3. Editor carets — peers' CodeMirror caret + selection rendered inside the editor (visible when two people have the same block in edit mode; otherwise the selection ring shows where they 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-js is present and authenticated) — no new infra, no DB tables, no migration:

  • Realtime Presence (track) carries identity + selected/focused blocks + editor caret (low frequency, auto-expires on disconnect).
  • Realtime Broadcast (cursor event) 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

Seam Role
appEffectsFacet channel lifecycle ({repo, workspaceId}) + global pointer-move broadcast
panelMountsFacet publishes the active panel's selection/focus/caret (gated on useIsActivePanel)
blockShellDecoratorsFacet remote-selection rings (imperative box-shadow on shellRef, stacked per peer)
appMountsFacet the mouse-cursor overlay (resolves [data-block-id] live each paint)
codeMirrorExtensionsFacet remote editor carets (StateField + ViewPlugin, fully decoupled from BlockEditor)

Registered in staticAppExtensions.ts as presencePlugin (a systemToggle). New code lives entirely under src/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.

⚠️ Spike caveats (deliberately not hardened yet)

  • Defaults ON when a hosted Supabase is configured (no-ops in local-only mode). Before shipping on-by-default, reconsider the default.
  • Public channel. Topic is presence:<workspaceId>; anyone who knows it can join. Hardening = Supabase Realtime Authorization (private channel + an RLS policy on realtime.messages gating join on workspace_members). The user JWT is already setAuth'd so that switch is forward-compatible.
  • e2ee leak. For 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.
  • The selection-ring decorator mutates shellRef.style.boxShadow imperatively (React never sets that prop, so it survives re-renders). A cleaner long-term option is adding style?: CSSProperties to BlockShellProps (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 check in 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 and node_modules only 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 the track()/presenceState() interface-vs-index-signature gotcha. Anything check flags I'll fix in follow-up commits.

Open questions for you

  • Default ON vs OFF, and how to handle e2ee workspaces (encrypt payload vs disable)?
  • Start with selections+cursors only and defer carets, or keep all three?

🤖 Generated with Claude Code


Generated by Claude Code

claude added 2 commits June 25, 2026 09:20
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/plugins/presence/presenceClient.ts Outdated
Comment on lines +101 to +102
const channel = supabase.channel(`presence:${workspaceId}`, {
config: { presence: { key: this.identity.clientId }, broadcast: { self: false } },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread src/plugins/presence/presenceClient.ts Outdated

private handlePointerMove(e: PointerEvent): void {
const target = e.target as Element | null
const el = target?.closest?.('[data-block-id]') as HTMLElement | null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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;` +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/plugins/presence/presenceClient.ts Outdated
// 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) })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread src/plugins/presence/presenceClient.ts Outdated
const client = supabase
const { workspaceId, user } = opts
this.identity = {
clientId: getClientId(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a per-tab presence key

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/plugins/presence/presenceClient.ts Outdated
})

const onPointerMove = (e: PointerEvent) => this.cursorThrottled(e)
const onPointerGone = () => this.broadcastCursor(null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +33 to +35
const editor = ed && ed.blockId === focusedBlockId
? { blockId: ed.blockId, start: ed.start ?? null, end: ed.end ?? ed.start ?? null }
: null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants