feat(collab): share-session link + vault-synced collabId (multi-user join)#185
Merged
Merged
Conversation
…join) Two complementary ways for a second person to join the same live-collab room, where room = the note's stable collabId UUID. Feature A — share-session link (works for anyone, no shared repo): - A "Share" button in the status bar, visible only when collab is configured (getConfiguredUrl() non-null). It mints/reuses the note's collabId, copies https://<origin>/?collab=<id>&title=<title> to the clipboard, and toasts. The UUID room id is the only credential. - On load, ?collab=<id> opens the matching local note, or creates an EMPTY one seeded with that collabId (joiner receives content over the CRDT wire — no local seed), then strips the params via replaceState. Feature B — vault-synced collabId (repo-sharers auto-share the room): - serializeNote emits a `collabId:` YAML frontmatter block ONLY for notes that carry one; parseNote reads it back. Round-trip is lossless (no blank line after the closing ---). Sync-safety: only collab notes re-serialize, so normal notes see zero churn (a note that gains a collabId is a one-time clean metadata update). The two-SHA classifier treats "gained a collabId" as a clean remoteUpdated, never a conflict; canonicalLocalSha now threads the collabId so baselines match and a re-pull settles to `unchanged`. A remote collabId wins so collaborators converge (local id kept when remote has none). The autoMerged + conflict-resolution apply paths re-parse merged bytes so frontmatter never leaks into the body. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two complementary ways for a second person to join the same live-collab room. The collab ENGINE (Y.Doc + y-websocket + awareness/cursors) already exists; this PR is purely about how two clients end up in the SAME room. The room = the note's stable
collabIdUUID.Feature A — share-session link (works for anyone, no shared repo)
getConfiguredUrl()non-null) and a note is open. Clicking it: ensures the note has acollabId(ensureCollabId), copieshttps://<origin>/?collab=<collabId>&title=<url-encoded title>to the clipboard, and toasts "Collaboration link copied. Anyone with the link can edit this note live."?collab=<id>opens the matching local note, or creates a new EMPTY local note seeded with thatcollabId(+ the title param). The joiner is seeded empty on purpose:createCollabBinding's seed-on-empty only fires for non-empty local content, so a joiner never pushes its own (empty) body into the room — it receives the room's content over the CRDT wire. Thecollab/titleparams are stripped viahistory.replaceStateso a refresh does not re-trigger.collabIdis a v4 UUID (unguessable). The link grants edit access to that one room and leaks nothing else — documented in the button tooltip + thecollabShare.tsheader.Feature B — vault-synced collabId (repo-sharers auto-share the room)
serializeNotenow emits acollabId:YAML frontmatter block, but only for notes that actually carry a collabId;parseNotereads it back intonote.collabId. Two people syncing the same GitHub vault therefore converge on the same room per note. Follows the existing conditional-frontmatter convention (extended, not a parallel system).Sync-safety (how churn is prevented)
collabIdserializes byte-for-byte as before (body only), so the overwhelming majority of notes are untouched. Only a note that has gained acollabIdre-serializes — a one-time clean metadata update for that one note.---and the body, soparseNotereturns exactly the body bytes and re-serializing reproduces identical bytes (no phantom leading-blank-line drift). Verified bycollabIdFrontmatter.test.ts(apply → re-pull settles tounchanged).remoteUpdated;canonicalLocalShanow threads thecollabIdso the baseline matchesserializeNote(note)and the note settles tounchanged. When both sides changed but the bodies are identical (only the collabId metadata differs), the classifier short-circuits to a cleanremoteUpdatedinstead of opening a conflict tab.undefined).autoMergedapply path and the conflict-resolution paths re-parse the merged bytes (like the manual-merge path already did), so acollabId:block can never end up insidenote.content.collabIdwhen a cloned note's body streams in, so cloned-vault notes join the right room.Constraints / gating
NEXT_PUBLIC_YJS_WS_URL(Preview/beta only). No Vercel/env changes. The Share button and all collab wiring are dormant in production.Tests
collabShare.test.ts— link generation (origin + collabId + encoded title, title omitted when blank, collab-before-title order), param parse, and the create-or-open join decision (joiner note is EMPTY; existing note reused, no duplicate; default title fallback).collabIdFrontmatter.test.ts— serialize/parse round-trip (with and without tags), gaining a collabId classifies asremoteUpdatednot conflict + re-pullsunchanged, and remote collabId convergence.collabExtension.test.ts— explicit joiner seed-skip (emptyinitialContentnever seeded, even after sync).npm run lintclean,tsc --noEmitzero errors.npm run e2e:syncran against the live test repo — all 17 scenarios pass, including the frontmatter round-trip and no-churn / special-char / smart-punctuation scenarios, confirming the serialization change does not break real clone/push/pull/round-trip or introduce churn.🤖 Generated with Claude Code