Repository navigation
feat: collaborative editing on Loro — realtime room, AI peer, co-authors - #2870
Conversation
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 Report SummaryPackage Details
This report is generated by SafeDep GitHub App |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| const isFirstDraftSave = !variables.draftId | ||
| if (isFirstDraftSave) crdtRef.current.adoptEditorOnBind() | ||
| acceptedDraftRef.current = draft | ||
| setDraftId(draft.id) |
There was a problem hiding this comment.
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 👍 / 👎.
| return this.db.transaction(async (tx) => { | ||
| await tx.execute( | ||
| sql`select pg_advisory_xact_lock(hashtext(${`crdt-edit:${parseEntityId(documentId)}`}))`, | ||
| ) | ||
| return run() |
There was a problem hiding this comment.
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 👍 / 👎.
- 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
# Conflicts: # apps/core/package.json




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
content_crdt_docssnapshot +content_crdt_updateslog). 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.collab.*events on/ws/adminrelay 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.@mx-space/editor/loro. Spec:docs/superpowers/specs/2026-10-25-collab-ai-peer-design.md.#/collab/join/<token>, and edits at#/collab/<documentId>over/ws/web. The co-author page has no agent, metadata or uploads.human:<readerId>. A peer id stays bound to one author.co_authors, typed in@mx-space/api-client.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
tscand eslint clean on changed filesco_authors; the byline toggle hides and restores itDatabase migrations checklist
0043,0044and0045only add tables, nullable columns and indexes on new tables.0043and0044carrymigration-lint:allow=no-bare-create-indexwith a reason. Same precedent as0035and0039.pnpm -C apps/core run lint:migrationspasses.migration-lint:allowannotations.The SQL files and journal entries are hand-written, because drizzle snapshots stop at
0040.0041and0042were done the same way.Deferred minors
collab.jointells readers whether a draft id exists (DRAFT_NOT_FOUNDvsFORBIDDEN).collab:rooms count in public web presence./ws/webis fixed at connect, so sign-out does not drop open rooms.content_collaborators.reader_idhas no index.