feat: realtime collaborative editing — live stream, presence, browser editor, CRDT co-editing, auto-merge - #207
Merged
Merged
Conversation
The hub streams "these paths changed" over SSE, so both the browser and the
sync daemon learn about a peer's write as it happens instead of on their
next poll.
- GET {prefix}events, proj(PermRead) — a per-project in-memory fan-out
(events.go), published from beside captureChange at the five write
handlers. Not inside it: that one is the PostHog path, whose contract is
that "nothing here carries a path or a file name", and whose signature is
counts-only.
- remote.Watcher, a new optional backend capability in the PutSigner mold.
Only httpBackend implements it; the daemon selects on its channel and
clears the remote gate, turning ~10s into ~1s. Object-store remotes never
had a hub to tell them and keep polling unchanged.
- useProjectEvents invalidates exactly what a write touched. This is what
makes an OPEN file update at all — useTextAt has no refetchInterval, so a
body fetched once previously stayed on screen until the reader navigated
away.
Strictly an accelerator: every interval still runs underneath, a slow
listener is told to resync rather than waited for, and a hub that is down,
too old to serve the route, or behind a buffering proxy degrades to exactly
today's behaviour.
Also fixes handleUndoRun, which wrote journal ops while calling neither
captureChange nor (now) publishChange — the gap captureChange's own comment
warns about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SSE frames held by a proxy until close turn live updates off with no error anywhere — sync keeps working at its old pace, which is the hard kind of problem to notice. nginx recipe, plus the note that Caddy and Cloudflare need nothing and that the fallback is simply today's behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
handleEvents only returned when the client disconnected, so anything that never disconnects held a subscriber forever — a half-open connection in production, and in tests a ResponseRecorder, which would have hung the whole package the first time someone poked the route with the ordinary do() helper. maxSubsTotal bounds concurrent streams, not abandoned ones. Streams now age out after an hour. Both clients already re-dial, but the daemon has to tell a clean expiry from a fast death or it would go blind for watchRetry once an hour on purpose: a stream that lived longer than the backoff re-dials at once, one that died quickly still backs off. Also: the resync now rides the keepalive, not only the next frame. A client that overflowed and then saw the project go quiet would otherwise stay stale until its stream aged out, because the traffic that would tell it to refetch is exactly the traffic that stopped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Go tests cover both ends of the wire — the hub publishes, the sync client wakes up. What only a browser can check is that a frame actually reaches TanStack Query and invalidates the right thing. The first spec is the one that pins the bug: file content has no refetchInterval, so before the change stream an open body stayed on screen until the reader navigated away — not for 15 seconds, indefinitely. It marks the document and re-checks the mark afterwards, so a passing run also proves no reload happened. Every budget is well under the 15s tree poll; a spec that waited 20s would pass on the poll alone and prove nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A heartbeat every 10s says which path you are on; the hub holds it for 15s and fans the roster out on the change stream that already exists. The top bar shows initials, ringed when someone is on the same file as you — which is the one question it exists to answer: am I about to collide with a teammate. Nothing is persisted and there is no MetaStore repo. Presence is true for fifteen seconds and then it is a lie, so writing it down would only create something to serve staler than the thing it describes; a hub restart empties it and the next heartbeat rebuilds it. The roster is keyed by account email and goes to every member of the project, so the key never appears in what they receive — display names and paths only, the same pair History already shows them. A claimed path is untrusted text echoed to teammates, so it goes through journal.SafePath rather than a fourth private copy of "looks fine to me". Expiry is lazy rather than a sweeper goroutine: the only people who need to know a roster shrank are the ones still in it, and they are exactly the ones still heartbeating. PermRead, not PermWrite — a read-only member is precisely who a teammate most wants to see on a file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
presenceHub, person, changeEvent.People on the server diagram; usePresence and PresenceBar on the frontend one. Both notes carry the two decisions that are not obvious from the types: the roster's actor key is an email that is never serialized, and expiry is lazy because the only people who need to know a roster shrank are the ones still heartbeating. All blocks parse-checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README gets a "Live" bullet; concepts/how-it-works gets a section next to the daemon's intervals, since that is where a reader forms their mental model of how long a change takes to arrive. Both say the same thing the code does: it is an accelerator, not a mechanism. An older hub, a buffering proxy, or an object-store remote falls back to the intervals and nothing else changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first version wrote to the seeded index.md, which browse.spec and admin.spec both assert is "Wiki" — so depending on file order it would have broken two other specs, and its own second run: the "before" it asserted was gone the moment it had run once. The harness is one hub with mutable state shared by every spec (workers: 1), so a spec has to establish its own precondition. These now write their own fixture file, and the "a new file appears" test takes a fresh path per run, since a file that already exists proves nothing about appearing. 20/20 on --repeat-each=5, sub-500ms each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e-sync # Conflicts: # internal/webapp/static/assets/index-Bf0LFaYC.css # internal/webapp/static/index.html
Reverses a stated product decision: the web app was a read/share/history
surface and content entered only through local sync. Owner call, 2026-09-06.
The server needed nothing new. upload/content has existed since browser
uploads were built — PermWrite, quota-checked, blobs-before-journal — with no
caller at all. This gives it one. That is also why the Mac app gets editing
for free: its sidecar already proxies that route to the hub, so a desktop
edit is journaled by the hub under the account exactly like any other write.
CodeMirror 6 rather than a textarea, because the CRDT layer attaches to a real
editor and doing this surface twice is not cheaper.
Saving is debounced autosave, not a Save button — that is what makes the
change stream worth having: a teammate watching the file sees it fill in
while you type instead of in one lump.
Two things the editor must never do, both found by driving a real browser:
- Rebuild on a re-render. The callbacks are inline arrows and reporting a save
state re-renders, so with them in the effect deps CodeMirror was torn down
per keystroke, resetting the cursor to 0 and writing the text back to front
("rieso ehtn T"). Deps are the document alone now; callbacks live in a ref.
- Re-seed from the server. `initial` changes when the seed query refetches,
and a peer's write invalidates exactly that query — so a teammate could
reset your buffer under your cursor. Seeded once from a ref; a peer write
raises a banner instead.
Still last-writer-wins: two people typing into one file collide exactly as two
laptops do. That is what the CRDT phase is for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sidecar never journals locally — every write proxies to the hub — so a change stream served from local state would be a connection that stays open and silent forever. /events and /presence join heat and shares as hub proxies, and the app sees a teammate's edit the moment the hub does. Two things had to change for a stream to survive the proxy, both of which would have failed silently rather than loudly: - initClient carries a 10s whole-request timeout, correct for every JSON call here and fatal for a connection whose job is to stay open with nothing to say. Streams get a client without one, same redirect policy so a stream cannot carry the device token off-origin either. - io.Copy buffers. An SSE frame is ~100 bytes and the response writer holds 4 KiB, so events would have sat there until something else filled the buffer — on an idle project, never. Streamed responses flush per read. Editing needed nothing: upload/content was already proxied, and HubApp already resolves the account's real hub permission into project.perm on desktop, which is what the Edit button reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Yjs between browsers, relayed by the hub. Two editors in the same paragraph now merge character by character instead of one clobbering the other and collecting a conflict copy. The split that keeps this safe: the DOCUMENT is a CRDT, the FILE is not. The hub relays opaque updates and never links a CRDT library — which keeps the build pure Go, the same reason modernc sqlite was chosen over cgo. Nothing here touches the journal: the document is snapshotted to an ordinary blob by an ordinary upload/content call from whichever client stopped typing last, so journal.Less and Replay are untouched and every desktop device, agent and older client converges exactly as before. The relay's log is deliberately not durable. The file is. The one piece of coordination a pure relay cannot avoid is who seeds the document from the file, because two clients seeding the same text produce two DIFFERENT Yjs documents and merging them duplicates every character. The first version inferred it from an empty update log, which a concurrency test immediately falsified: the log only fills after the seeder posts, so every joiner arriving inside that window was told to seed as well (32 of 32). The claim is now taken at join under the room lock — and released again if the claimer leaves without typing, or a client that opened a file and closed it would leave the room claimed but empty, and the next joiner would snapshot that emptiness over a real file. Co-editing is browser-to-browser. A desktop editor writing the same file is still a whole-file write, resolved the way it always was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two people editing one text file used to fork it: last-writer-wins at the path, loser preserved as a .bdrive-conflict-* copy, and a hand-merge for somebody. When the edits are in different parts of the file — the common case — the machine can just do it. Three-way merge against the common ancestor, which the journal already has: the greatest op for that path ordered before BOTH sides is the state each side started from. Hand-written, no dependency, the same call lib/diff.ts made and documented; this one runs inside conflictCopies, which has a security history, so the code deciding what a merged file contains should be readable in one sitting. It declines far more than it resolves, on purpose. No common ancestor, non-UTF-8 or NUL bytes, over 4 MiB, a blob that will not read, or edits that touch the same lines — every one of those falls through to exactly the conflict copy that existed before. Two rewrites of one sentence is how people lose work quietly, and a machine guessing between them is worse than a file they have to look at. Order independence is the property that makes this safe on more than one device: two peers can each observe the same concurrency and merge independently, so merge(base,a,b) must equal merge(base,b,a) or they push different blobs and one side's work vanishes under last-writer-wins. Only non-overlapping regions are ever taken, which makes the result the union either way, and a test pins it. Result.Merged is separate from Result.Conflicts because they mean opposite things to the person reading the line: a merge is work the machine finished, a conflict is work it handed back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Awareness was never relayed, so two things silently did not work: a co-editor's caret never appeared, and peerCount stayed at zero forever. Awareness is broadcast but NEVER recorded in the room's log. It says where somebody's caret is this second — replaying it to a joiner would paint cursors for people who have gone home, and storing it would grow the room without bound for something that has no history worth keeping. That fixes a real bug the demo recording exposed: during ordinary co-editing BOTH panes showed "Someone else changed this file while you were editing". Each client's snapshot save fires a change event the other reads as an outside write — but a co-editor's text is already in your buffer by construction. The banner now fires only when nobody else is in the room, which is exactly the case it exists for: a CLI or another device editing the file from outside, where the room's document really will win. Caret colour is derived from the account rather than assigned, so the same person is the same colour on everyone's screen and across sessions; an index would re-colour the room whenever somebody left. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
collabHub/collabRoom on the server diagram, collab.ts and Editor on the frontend one, and the merge on the sync one. Each note carries the decision that is not visible in the types: the seed claim is taken at join rather than inferred from an empty log, awareness is relayed but never recorded, and the merge only ever takes non-overlapping regions so two devices merging the same concurrency independently cannot disagree. All blocks parse-checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding co-editing broke editing on the Mac app, two commits after making it work. The editor mounts CodeMirror on the relay's first frame, and the desktop never proxied /collab — so the request fell through to the local handler, which resolves every project to PermRead, answered 403, and the pane never appeared at all. Two fixes, because either one alone leaves a hole: - /collab joins /events and /presence as a hub proxy. The document lives in the hub's relay and its snapshot is a hub write, so there is nothing local to serve. Its GET is marked streaming, or the 10s client timeout severs it. - The editor no longer depends on the relay existing. A CollabDoc that has never once connected reports itself unavailable and the editor opens single-writer — the same editing that worked before co-editing existed. Without this, ANY hub too old to serve /collab gives a new frontend a blank editor, which is a worse failure than not having the feature. Verified with a browser on the desktop sidecar and a browser on the hub typing into one document: both keep their text, both converge, and the journaled file has both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five conflicts, four in real source. Two resolutions worth naming: - desktop.go: main replaced hand-registered proxies with a desktopRoutes table and a test that fails when the hub grows a per-project route nobody classified. That test is exactly the safeguard the /collab omission on this branch needed, so my four live routes went INTO the table rather than beside it. - Browser.tsx: most of that conflict was mine to answer for. I had run `npx prettier` without the repo's width, which reflowed lines I never touched. Took main's file and re-applied my changes onto its formatting. Two real interactions between the two features, both fixed rather than discovered later: - upload/content now runs writablePath, so a project-level canEdit would put an Edit button on a read-only FOLDER that 403s on the first save — the "button that 403s" Share deliberately avoids. canEdit reads FolderRule.me, the hub's own answer, instead of recomputing one. - /collab was project-level PermWrite only. A member with folder read could have joined the room and typed: keystrokes relayed to every co-editor and then failed to persist, because the snapshot goes through upload/content, which does check. Both halves of collab now run the same folder gate — better to refuse the session than to show people text that cannot survive. Verified on the merged tree: web co-editing, Mac-app-to-web co-editing, and a new check that a read-only folder hides the Edit button and refuses collab while a writable path in the same project still offers it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The change stream made this test's own premise obsolete. When the missing file arrives, the stream invalidates the tree, the path stops being missing, and the not-found view unmounts — so the click landed on a button that had already gone, and Playwright timed out waiting for an element it had just resolved. The button still matters: a hub with no stream (older version, a proxy that buffers SSE) recovers only when someone asks it to. So the spec clicks it if it is still there and asserts what actually matters either way — the reader ends up looking at the file. Co-Authored-By: Claude Opus 5 (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.
TL;DR
.bdrive-conflict-*copy.What this is
Four things, each usable on its own, built in that order.
A live change stream. The hub streams "these paths changed" over SSE. The sync daemon holds it open and syncs on the news rather than waiting out its 10s remote interval; the browser listens on the same stream. This also fixes a bug that was never latent: file content has no refetch interval, so a teammate's edit never refreshed an open file — not for fifteen seconds, indefinitely.
It is strictly an accelerator. Every interval still runs underneath, so an older hub, a hub that is down, a buffering proxy, or an object-store remote with no hub at all falls back to exactly today's behaviour.
Presence. A 10s heartbeat naming the file you're on, held for 15s, never written to disk. Presence is true for fifteen seconds and then it's a lie, so storing it would only create something to serve staler than the thing it describes.
An editor. This reverses a stated product decision — the web app was a read/share/history surface and content entered only through local sync. The server needed nothing new:
upload/contenthas existed since browser uploads were built,PermWriteand quota-checked, with no caller at all. That is also why the Mac app gets editing for free — its sidecar already proxies that route to the hub.Co-editing. Yjs between browsers, relayed by the hub. The split that keeps it safe: the document is a CRDT, the file is not. The hub relays opaque updates and links no CRDT library, which keeps the build pure Go — the same reason
moderncsqlite was chosen over cgo. Nothing touches the journal: the document is snapshotted to an ordinary blob by an ordinaryupload/contentcall, sojournal.LessandReplayare untouched and every desktop device, agent and older client converges exactly as before.Auto-merge. Three-way merge against the common ancestor the journal already holds. It declines far more than it resolves — no ancestor, non-UTF-8, over 4 MiB, or edits touching the same lines all fall through to the conflict copy that existed before. A machine guessing between two rewrites of one sentence is how people lose work quietly.
The Mac app
The desktop sidecar serves the same
/api/*contract over local state, and its rule is that it never journals locally — every write proxies to the hub. So editing needed nothing:upload/contentwas already proxied, andHubAppalready resolves the account's real hub permission intoproject.perm, which is what the Edit button reads.The live surfaces did need wiring:
/events,/presenceand/collabare hub state, and two things had to change for a stream to survive the proxy —initClient's 10s whole-request timeout would sever it, andio.Copybuffers 4 KiB so ~100-byte SSE frames would never arrive.Adding co-editing initially broke desktop editing: the editor mounts on the relay's first frame, and with
/collabunproxied that request fell through to the local PermRead handler, 403'd, and the pane never appeared. Fixed two ways — the route is proxied, and the editor now falls back to single-writer editing when the relay is unreachable, so an older hub gives a working editor rather than a blank one.Verified with a browser on the desktop sidecar and a browser on the hub typing into one document: both keep their text, both converge, the journaled file has both.
Backward compatibility
Verified in both directions with real binaries built from the merge base, not by reasoning:
/events404s and the daemon degrades to polling, with no log spaminternal/journal,internal/storeandinternal/syncer's wire format have zero diff./events,/presenceand/collabare purely additive routes;remote.Watcheris an optional capability in the existingPutSignermold.Three bugs the tests caught, each of which would have shipped silently
"rieso ehtn T".A fourth was caught by recording the demo: during ordinary co-editing both panes showed "Someone else changed this file while you were editing", because each client's snapshot fires a change event the other read as an outside write.
Known gaps
Verification
Conflicts = 0; overlapping edits and binary files still forkinternal/syncer,internal/webapp,cmd/bdrive(incl. everysec_*suite),store,journal,daemontsc,gofmtclean, all four mermaid diagrams parse-checkedNot run: the full Playwright suite, which is contended on this machine — several other worktrees are using the reserved e2e ports. The four
live.spec.tsspecs pass 20/20 in isolation.Architecture changes
architecture/webapp-server.mdWatcherjoinsPutSigneras an optionalBackendcapability.Servergained three in-memory services:eventHub(the change stream and itssubscriber/changeEvent),presenceHub(+person), andcollabHub(+collabRoom).presenceHubpublishes on the event stream rather than owning one, andcollabRoomreuses the samesubscriberfan-out.flowchart TB Server["<div style='text-align:left'><b>Server</b><br/>+Root remote.Backend<br/>+Auth AuthProvider<br/>+Quota QuotaProvider<br/>-vols per-project volume cache<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -ev *eventHub</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -pres *presenceHub</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -col *collabHub</span></div>"] Backend["<div style='text-align:left'><b>Backend</b><br/><<interface>><br/>+Put +Get +List +Exists +Close</div>"] PutSigner["<div style='text-align:left'><b>PutSigner</b><br/><<interface>><br/>+SignPut(ctx, key, size, ttl)</div>"] Watcher["<div style='text-align:left'><b>Watcher</b><br/><<interface>><br/>+Watch(ctx) signal channel</div>"] eventHub["<div style='text-align:left'><b>eventHub</b><br/>-subs per project id<br/>+subscribe(project) sub, ok<br/>+publish(project, changeEvent)</div>"] subscriber["<div style='text-align:left'><b>subscriber</b><br/>-ch chan of frames<br/>-lost atomic.Bool</div>"] changeEvent["<div style='text-align:left'><b>changeEvent</b><br/>+Type change or resync<br/>+Paths list<br/>+People roster, presence only</div>"] presenceHub["<div style='text-align:left'><b>presenceHub</b><br/>-at per project, per actor<br/>+mark(...) roster, changed<br/>+drop(...) roster, changed</div>"] person["<div style='text-align:left'><b>person</b><br/>+Name string<br/>+Path string</div>"] collabHub["<div style='text-align:left'><b>collabHub</b><br/>-rooms per project and path<br/>+room(key) collabRoom</div>"] collabRoom["<div style='text-align:left'><b>collabRoom</b><br/>-updates opaque Yjs updates<br/>-seeded claimed at join<br/>+join(sub) log, first<br/>+post(update, from) ok<br/>+relay(frame) not logged</div>"] SeedNote["seeded is CLAIMED at join, never inferred<br/>from an empty log: the log only fills after<br/>the seeder POSTs, so joiners inside that<br/>window were told to seed too (32 of 32).<br/>Two seeds of the same text are two different<br/>Yjs docs, and a merge doubles every character."] JournalNote["Nothing here touches the journal.<br/>The DOCUMENT is a CRDT between browsers.<br/>The FILE is an ordinary blob via upload/content.<br/>journal.Less and Replay are untouched."] Backend --> PutSigner Backend -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ optional capability</span>" --> Watcher Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ live change fan-out</span>" --> eventHub Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ who is looking at what</span>" --> presenceHub Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ per-document editing relay</span>" --> collabHub eventHub --> subscriber eventHub -.- changeEvent presenceHub -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ publishes roster on the SAME stream</span>" --> eventHub presenceHub -.-> person collabHub --> collabRoom collabRoom -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ reuses the event fan-out</span>" --> subscriber collabRoom -.- SeedNote collabHub -.- JournalNote classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class Watcher,eventHub,subscriber,changeEvent,presenceHub,person,collabHub,collabRoom added class SeedNote,JournalNote noteBox linkStyle 1 stroke:#22c55e,stroke-width:2px linkStyle 2 stroke:#22c55e,stroke-width:2px linkStyle 3 stroke:#22c55e,stroke-width:2px linkStyle 4 stroke:#22c55e,stroke-width:2px linkStyle 5 stroke:#22c55e,stroke-width:2px linkStyle 7 stroke:#22c55e,stroke-width:2px linkStyle 8 stroke:#22c55e,stroke-width:2px linkStyle 9 stroke:#22c55e,stroke-width:2px linkStyle 10 stroke:#22c55e,stroke-width:2pxarchitecture/webapp-frontend.mdhooksgaineduseProjectEventsandusePresence;libgainedcollab.ts;componentsgainedEditorandPresenceBar. TheBrowser → hooksedge now names the event hook.flowchart TB Browser["<div style='text-align:left'><b>Browser</b><br/>folder listing, file view<br/>per-view routes</div>"] hooks["<div style='text-align:left'><b>hooks</b><br/>+useConfig<br/>+useHub<br/>+useBrowse<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +useProjectEvents (SSE → invalidate)</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +usePresence (10s heartbeat)</span><br/>+useTextAt → useBlobText</div>"] lib["<div style='text-align:left'><b>lib</b><br/>+diff.ts splitLines lcsDiff<br/>+heat.ts heatFor heatTotal<br/>+conflict.ts parseConflict<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +collab.ts CollabDoc peerCount</span></div>"] components["<div style='text-align:left'><b>components</b><br/>FileView FolderListing FileTree<br/>HistoryView DiffView ConflictBanner<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ Editor</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ PresenceBar</span></div>"] CollabNote["collab.ts is the Yjs provider:<br/>SSE down, POST up — the same pair<br/>/events already uses. No websocket,<br/>no upgrade handshake.<br/>Awareness is posted separately<br/>and never logged."] BufferNote["The editor never re-seeds from the server.<br/>`initial` changes when the seed query<br/>refetches, and a peer's write invalidates<br/>exactly that query — so it would reset<br/>the buffer under the typist's cursor."] Browser -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ + useProjectEvents</span>" --> hooks Browser --> components components --> lib hooks --> lib lib -.- CollabNote components -.- BufferNote classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class CollabNote,BufferNote noteBox linkStyle 0 stroke:#22c55e,stroke-width:2pxarchitecture/cli-sync.mdA new
mergeunit (internal/syncer/merge.go) thatSessionconsults before making a conflict copy.daemongained a third select arm onremote.Watcher.flowchart TB Session["<div style='text-align:left'><b>Session</b><br/>+Cycle(ctx) Result<br/>-scan, pull, materialize, push<br/>-conflictCopies(...)</div>"] merge["<div style='text-align:left'><b>merge</b><br/>+tryMerge(path, mine, theirs, all) blob, ok<br/>+mergeText(base, a, b) out, ok<br/>+commonAncestor(path, mine, theirs, all)<br/>-maxMergeBytes 4 MiB, UTF-8 only</div>"] Result["<div style='text-align:left'><b>Result</b><br/>+LocalOps +PulledOps<br/>+Conflicts int<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Merged int</span></div>"] daemon["<div style='text-align:left'><b>daemon</b><br/>+Run(folder, scan, remote)<br/>+Start / Stop / Running<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ selects on remote.Watcher</span></div>"] Store["<div style='text-align:left'><b>Store</b><br/>blobs + journals + state</div>"] OrderNote["Only NON-overlapping regions are taken,<br/>so merge(base,a,b) == merge(base,b,a).<br/>That is what lets two devices observe the<br/>same concurrency and merge independently<br/>without pushing different blobs."] DeclineNote["Declines far more than it resolves:<br/>no ancestor, non-UTF-8 or NUL, over 4 MiB,<br/>an unreadable blob, or edits touching the<br/>same lines all fall through to the conflict<br/>copy that existed before."] Session -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ before a conflict copy</span>" --> merge Session --> Store Session --> Result merge -.- OrderNote merge -.- DeclineNote classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class merge added class OrderNote,DeclineNote noteBox linkStyle 0 stroke:#22c55e,stroke-width:2px