Skip to content

feat: collaborative editing on Loro — realtime room, AI peer, co-authors - #2870

Merged
Innei merged 20 commits into
masterfrom
feat/loro-collab
Oct 25, 2026
Merged

Innei merged 20 commits into
masterfrom
feat/loro-collab

Conversation

@Innei

@Innei Innei commented Oct 25, 2026

Copy link
Copy Markdown
Member

Collaborative editing for Lexical drafts, in four phases. A Loro CRDT doc becomes the source of truth for draft content; owner tabs, the AI agent and invited readers edit it live in one room.

Summary

  • Phase 1: Loro working copy. Each Lexical draft is stored as one Loro doc (content_crdt_docs snapshot + content_crdt_updates log). Checkpoints, restore and undo work on frontiers. The admin editor binds through @haklex/rich-collab-loro. Spec: docs/superpowers/specs/2026-10-25-loro-content-storage-design.md.
  • Phase 2: realtime room. collab.* events on /ws/admin relay updates and presence across pods through the ws bus, with an HTTP fallback. Admin shows peer avatars and remote carets. Spec: docs/superpowers/specs/2026-10-25-collab-realtime-room-design.md.
  • Phase 3: AI peer. The document bash tool stays in the browser. Agent writes land in the Loro doc under their own peer, diffed against what the agent read. Each turn is one timeline step and can be undone. The Loro helpers move to @mx-space/editor/loro. Spec: docs/superpowers/specs/2026-10-25-collab-ai-peer-design.md.
  • Phase 4: co-authors.
    • The owner creates invite links; tokens are stored as hashes.
    • A reader signs in with an OAuth provider, accepts at #/collab/join/<token>, and edits at #/collab/<documentId> over /ws/web. The co-author page has no agent, metadata or uploads.
    • The server rejects co-author updates unless every change is signed human:<readerId>. A peer id stays bound to one author.
    • The server stamps presence with the session identity. Co-author frames are rate limited.
    • Removing a co-author drops them from the room on every pod. Invite links made before the removal cannot re-admit them.
    • Post and note detail responses carry co_authors, typed in @mx-space/api-client.
    • Spec: docs/superpowers/specs/2026-10-25-collab-coauthors-design.md.

Known limitation (accepted): co-authors receive the full Loro oplog, so text deleted before they joined can be recovered with dev tools. A shallow snapshot would break concurrent owner ops whose dependencies predate the shallow root. The collaborators panel tells the owner this.

Test plan

  • core: contracts, post, note, draft, gateway and ai suites: 152 files, 1288 tests pass (real PG testcontainers for the repositories)
  • admin: write, collab and rich-editor suites pass; tsc and eslint clean on changed files
  • Local end to end, owner in browser A, reader in browser B via a temporary local session:
    • invite → accept → co-author page
    • both edit body and title live over WS, with named carets and avatars
    • the timeline names co-author steps
    • publish returns co_authors; the byline toggle hides and restores it
    • removal shows a revoked page; HTTP returns 403 and WS join returns FORBIDDEN
    • an old link returns 410 and a new link re-admits
  • Phases 1–3 end to end:
    • two owner tabs with remote carets
    • restore while live; a core restart loses nothing
    • a concurrent AI rewrite merges with human typing; undo of an agent turn
  • OAuth provider redirect on the join page. Not exercised locally because no social provider is configured.

Database migrations checklist

  • Migration is backwards-compatible with the previous release's code. 0043, 0044 and 0045 only add tables, nullable columns and indexes on new tables.
  • No drop / rename / type change.
  • Indexes are created only on brand-new, empty tables. 0043 and 0044 carry migration-lint:allow=no-bare-create-index with a reason. Same precedent as 0035 and 0039.
  • pnpm -C apps/core run lint:migrations passes.
  • Reviewer agrees with the two migration-lint:allow annotations.
  • No follow-up contract migration needed.

The SQL files and journal entries are hand-written, because drizzle snapshots stop at 0040. 0041 and 0042 were done the same way.

Deferred minors

  • Joins are not rate limited, and buckets are per connection rather than per reader.
  • collab.join tells readers whether a draft id exists (DRAFT_NOT_FOUND vs FORBIDDEN).
  • collab: rooms count in public web presence.
  • The reader identity on /ws/web is fixed at connect, so sign-out does not drop open rooms.
  • Every relay frame is published to both the admin and web namespaces.
  • content_collaborators.reader_id has no index.
  • The co-author toolbar still shows the image button, though uploads are owner-only.

Innei added 17 commits October 25, 2026 15:50
Content documents in Lexical keep a Loro doc as the source of truth with a
linear checkpoint timeline. Adds HTTP sync, checkpoints, field patches,
history, restore and undo endpoints; legacy draft writes apply as snapshots
at their base version. Markdown keeps the branch flow.
The write page binds the editor to the draft's Loro doc, syncs over HTTP,
patches fields separately and replaces the branch picker with a checkpoint
timeline that can restore and undo. Agent edits land directly.
Follow-mode share links resolve to the latest checkpoint, so the 10-second
idle save must keep creating one.
Admin sockets join collab:<documentId> rooms. collab.update stores the
update through the same vetted import path and relays every stored change,
including server-side restores and undos, to the other members across pods.
Presence frames are relayed without storage.
The write page joins the document's collab room on the admin socket,
pushes updates as frames and applies relayed ones directly. HTTP stays the
fallback while the socket is down, and a reconnect re-joins with the local
version to catch up.
Members exchange a Loro ephemeral presence over the collab room. Carets and
selections are anchored to Loro tree ids so they land on the same text in
every editor; the write page draws them with names and shows an avatar
stack in the header.
- join the room before reading the snapshot and buffer relayed frames
  while the join is in flight, so no update falls between
- refuse admin socket extension and room events until the connection
  has authenticated
- load over HTTP when the socket cannot join, and join whenever the
  socket opens
- undo joins whose connection closed or whose channel was disposed
  mid-flight; require a join before collab.update; add a replaceable
  access check
- keep presence updates out of the write page's render path
The headless node set, readDocContent and editDocAt move out of core so the
admin can merge agent writes with the same code. editDocAt can now commit
under a given peer.
On the Loro path the document workspace hands each write to an agent turn
instead of the review store. The turn merges it at the version the agent
read, commits it under a per-run peer with an agent: message, and shows an
AI caret to everyone in the room. New posts are saved before the first run,
and the prompt describes direct, shared edits.
Ops imported from the agent fork are not local updates, so the sync loop is
told to send them. The agent's view is taken when its workspace reads the
document rather than when the turn opens, so text typed in between is not
treated as the agent's own and merged twice.
… turn

- untouched blocks keep their JSON instead of the LiteXML round trip
- the workspace reports its own copy while it owns the writes
- title, writes and turn end follow the view the agent read
- one first-save attempt at a time, and a toast when the document never goes live
- content_collaborators and content_collab_invites (migration 0044)
- invite links stored as token hashes; owner routes for links and collaborators
- co-authors join the room over /ws/web; updates must be signed human:<readerId>
- presence is stamped by the server; co-author frames are rate limited
- removing a collaborator drops their sockets on every pod
- post and note detail carry co_authors
- #/collab/join/<token> signs a reader in and accepts the invite
- #/collab/<documentId> edits the shared draft over /ws/web, no agent or uploads
- co-authors panel in the history aside: invite links, byline toggle, remove
- timeline names co-author steps
- presence frames carry only carets; names and avatars come from the server
- removal marks the row; links made before it cannot re-admit the reader
- removal drops the member on this pod at once, not only via the bus
- a join that lost access while loading backs out of the room
- a peer id stays bound to one author, so co-authors cannot claim the owner's op ids
- /ws/web waits for the session lookup before answering collab frames
- the owner identity is cached instead of queried on every frame
- collaborators list returns removed people with removed_at; panel and co-author page hide them
- timeline falls back to a generic co-author label instead of a raw reader id
- contributed_at uses the database clock so byline order is stable
@safedep

safedep Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

SafeDep Report Summary

Green Malicious Packages Badge Green Vulnerable Packages Badge Green Risky License Badge

Package Details
Package Malware Vulnerability Risky License Report
icon @haklex/rich-collab-loro @ 0.55.0
apps/core/package.json pnpm-lock.yaml packages/editor/package.json
ok icon
ok icon
ok icon
🔗
icon loro-crdt @ 1.16.4
apps/core/package.json pnpm-lock.yaml packages/editor/package.json
ok icon
ok icon
ok icon
🔗

View complete scan results →

This report is generated by SafeDep GitHub App

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-25T11:10:29.697060Z 350cb01 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 350cb01432

ℹ️ 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 902 to 905
const isFirstDraftSave = !variables.draftId
if (isFirstDraftSave) crdtRef.current.adoptEditorOnBind()
acceptedDraftRef.current = draft
setDraftId(draft.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.

P1 Badge Initialize the fields baseline after the first draft save

When a new Lexical draft is first saved, ackedFieldsRef.current remains null: this success handler updates acceptedDraftRef directly, and the route-loading effect explicitly skips accept() for the draft created by our own save. Once CRDT becomes active, both sendFields() and the fields autosave effect return early without that baseline. Subsequent category, slug, tags, and other metadata edits therefore are not persisted, even by explicit save or publish, which only flush the CRDT and create a checkpoint. Initialize the acknowledged fields from the first successful save before activating the session.

Useful? React with 👍 / 👎.

Comment on lines +64 to +68
return this.db.transaction(async (tx) => {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtext(${`crdt-edit:${parseEntityId(documentId)}`}))`,
)
return run()

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 Run locked operations on the transaction's connection

With concurrent locked edits, these transactions can exhaust the PostgreSQL pool while run() needs another connection from that same pool. For example, concurrent restore/undo/legacy-save requests for one document can occupy every connection waiting on its advisory lock, leaving the lock holder unable to execute the repository reads needed to finish and release it. With PG_MAX_POOL_SIZE=1, even one such operation hangs. The pool configuration in postgres.provider.ts has no acquisition timeout, so this can stall unrelated database requests indefinitely. Pass the transaction connection through the locked operation instead of reserving one connection solely for the lock.

Useful? React with 👍 / 👎.

Innei added 3 commits October 25, 2026 19:50
- 24px overlapping avatars, one per person, colored edge per member
- avatar images that fail to load fall back to the initial
- members on one screen get distinct caret colors while the palette lasts
@Innei
Innei merged commit cfb24a0 into master Oct 25, 2026
16 checks passed
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.

1 participant