···11+# Granular Delta Edits Implementation Plan
22+33+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
44+55+**Goal:** Replace full-document WebSocket broadcasts with granular character-level deltas, reducing per-keystroke payload from O(document size) to O(change size).
66+77+**Architecture:** CodeMirror exposes `update.changes` (a `ChangeSet`) directly — iterate it to extract precise `{from, to, insert}` operations. Milkdown only exposes the new markdown string, so we string-diff the previous vs new content using a bundled Myers diff to produce the same operation shape. The server-side `Operation` struct and `OTEngine` already handle granular ops correctly (after the `Insert==""` fix). Add a WebSocket send debounce (50ms) to batch rapid keystrokes. Receivers apply each delta in order using CodeMirror's `ChangeSet` API rather than replacing the whole document.
88+99+**Tech Stack:** Go 1.22, CodeMirror 6 (`ChangeSet`, `ChangeDesc`), vanilla JS Myers diff (bundled inline, no new npm dep), existing `gorilla/websocket`, existing `Operation` / `OTEngine` types.
1010+1111+---
1212+1313+## Chunk 1: Server — accept and broadcast multiple deltas per message
1414+1515+The current server receives one `Operation` per `edit` message. Granular edits from a single keystroke may produce multiple operations (e.g. CodeMirror reports each change in a transaction separately). We need the server to accept an array.
1616+1717+### Task 1: Update `Operation` and `ClientMessage` to support delta arrays
1818+1919+**Files:**
2020+- Modify: `internal/collaboration/client.go`
2121+- Modify: `internal/collaboration/hub.go`
2222+- Test: `internal/collaboration/client_test.go` (create)
2323+2424+The wire format changes from:
2525+```json
2626+{ "type": "edit", "delta": { "from": 5, "to": 10, "insert": "hello" } }
2727+```
2828+to:
2929+```json
3030+{ "type": "edit", "deltas": [{ "from": 5, "to": 10, "insert": "hello" }] }
3131+```
3232+3333+`delta` (singular) is kept as a fallback field so existing clients don't break during the transition.
3434+3535+- [ ] **Step 1: Write failing test for multi-delta parsing**
3636+3737+Add `internal/collaboration/client_test.go`:
3838+3939+```go
4040+package collaboration
4141+4242+import (
4343+ "encoding/json"
4444+ "testing"
4545+)
4646+4747+func TestClientMessage_ParseDeltas_Multiple(t *testing.T) {
4848+ raw := `{"type":"edit","deltas":[{"from":0,"to":5,"insert":"hello"},{"from":10,"to":10,"insert":"!"}]}`
4949+ var msg ClientMessage
5050+ if err := json.Unmarshal([]byte(raw), &msg); err != nil {
5151+ t.Fatalf("unmarshal: %v", err)
5252+ }
5353+ if len(msg.Deltas) != 2 {
5454+ t.Fatalf("expected 2 deltas, got %d", len(msg.Deltas))
5555+ }
5656+ if msg.Deltas[0].From != 0 || msg.Deltas[0].To != 5 || msg.Deltas[0].Insert != "hello" {
5757+ t.Errorf("delta[0]: %+v", msg.Deltas[0])
5858+ }
5959+ if msg.Deltas[1].From != 10 || msg.Deltas[1].Insert != "!" {
6060+ t.Errorf("delta[1]: %+v", msg.Deltas[1])
6161+ }
6262+}
6363+6464+func TestClientMessage_ParseDeltas_SingleFallback(t *testing.T) {
6565+ // Old wire format: singular "delta" field — must still work.
6666+ raw := `{"type":"edit","delta":{"from":3,"to":7,"insert":"xyz"}}`
6767+ var msg ClientMessage
6868+ if err := json.Unmarshal([]byte(raw), &msg); err != nil {
6969+ t.Fatalf("unmarshal: %v", err)
7070+ }
7171+ ops := msg.Operations()
7272+ if len(ops) != 1 {
7373+ t.Fatalf("expected 1 op from fallback, got %d", len(ops))
7474+ }
7575+ if ops[0].From != 3 || ops[0].To != 7 || ops[0].Insert != "xyz" {
7676+ t.Errorf("op: %+v", ops[0])
7777+ }
7878+}
7979+8080+func TestClientMessage_ParseDeltas_EmptyDeltas(t *testing.T) {
8181+ raw := `{"type":"edit","deltas":[]}`
8282+ var msg ClientMessage
8383+ if err := json.Unmarshal([]byte(raw), &msg); err != nil {
8484+ t.Fatalf("unmarshal: %v", err)
8585+ }
8686+ if len(msg.Operations()) != 0 {
8787+ t.Errorf("expected 0 ops for empty deltas")
8888+ }
8989+}
9090+```
9191+9292+- [ ] **Step 2: Run tests to verify they fail**
9393+9494+```bash
9595+go test -v ./internal/collaboration/ -run TestClientMessage
9696+```
9797+9898+Expected: `FAIL` — `msg.Deltas` field doesn't exist, `msg.Operations()` method doesn't exist.
9999+100100+- [ ] **Step 3: Update `ClientMessage` in `internal/collaboration/client.go`**
101101+102102+Replace the existing `ClientMessage` struct and add the `Operations()` helper:
103103+104104+```go
105105+type ClientMessage struct {
106106+ Type string `json:"type"`
107107+ RKey string `json:"rkey,omitempty"`
108108+ DID string `json:"did,omitempty"`
109109+ // Deltas is the new plural field — a single edit message may carry
110110+ // multiple operations (e.g. one per CodeMirror ChangeDesc).
111111+ Deltas []Operation `json:"deltas,omitempty"`
112112+ // Delta is the legacy singular field. Kept for backward compatibility.
113113+ Delta json.RawMessage `json:"delta,omitempty"`
114114+ Cursor *CursorPos `json:"cursor,omitempty"`
115115+ Comment *CommentMsg `json:"comment,omitempty"`
116116+}
117117+118118+// Operations returns the ops from this message, preferring Deltas over the
119119+// legacy singular Delta field.
120120+func (m *ClientMessage) Operations() []Operation {
121121+ if len(m.Deltas) > 0 {
122122+ return m.Deltas
123123+ }
124124+ if len(m.Delta) > 0 {
125125+ var op Operation
126126+ if err := json.Unmarshal(m.Delta, &op); err == nil {
127127+ return []Operation{op}
128128+ }
129129+ }
130130+ return nil
131131+}
132132+```
133133+134134+- [ ] **Step 4: Update the `"edit"` case in `ReadPump` in `internal/collaboration/client.go`**
135135+136136+Replace:
137137+```go
138138+case "edit":
139139+ var op Operation
140140+ if err := json.Unmarshal(msg.Delta, &op); err != nil {
141141+ log.Printf("Failed to parse delta from %s: %v", c.DID, err)
142142+ continue
143143+ }
144144+ op.Author = c.DID
145145+ room := c.hub.GetRoom(c.roomKey)
146146+ if room != nil {
147147+ room.ApplyEdit(op, c)
148148+ }
149149+```
150150+151151+With:
152152+```go
153153+case "edit":
154154+ ops := msg.Operations()
155155+ if len(ops) == 0 {
156156+ continue
157157+ }
158158+ room := c.hub.GetRoom(c.roomKey)
159159+ if room == nil {
160160+ continue
161161+ }
162162+ for i := range ops {
163163+ ops[i].Author = c.DID
164164+ }
165165+ room.ApplyEdits(ops, c)
166166+```
167167+168168+- [ ] **Step 5: Add `ApplyEdits` to `Room` in `internal/collaboration/hub.go`**
169169+170170+Add alongside the existing `ApplyEdit` method:
171171+172172+```go
173173+// ApplyEdits applies a sequence of operations in order and broadcasts one
174174+// combined message to all other clients. Each op is applied to the text
175175+// resulting from the previous op, so positions in each op must be relative
176176+// to the document state after all prior ops in the same batch have been
177177+// applied — which is exactly what CodeMirror's iterChanges produces (fromA/toA
178178+// are positions in the pre-change document, already adjusted for prior changes
179179+// within the same transaction by CodeMirror itself).
180180+func (r *Room) ApplyEdits(ops []Operation, sender *Client) {
181181+ if len(ops) == 0 {
182182+ return
183183+ }
184184+185185+ for i := range ops {
186186+ r.ot.ApplyWithVersion(ops[i])
187187+ }
188188+189189+ // Include the full document text so receivers can detect and recover from
190190+ // divergence without a reconnect.
191191+ finalText := r.ot.GetText()
192192+ type editsMsg struct {
193193+ Type string `json:"type"`
194194+ Deltas []Operation `json:"deltas"`
195195+ Author string `json:"author"`
196196+ Content string `json:"content"`
197197+ }
198198+ msg := editsMsg{
199199+ Type: "edit",
200200+ Deltas: ops,
201201+ Author: sender.DID,
202202+ Content: finalText,
203203+ }
204204+ data, err := json.Marshal(msg)
205205+ if err != nil {
206206+ log.Printf("ApplyEdits: marshal: %v", err)
207207+ return
208208+ }
209209+ r.BroadcastExcept(data, sender)
210210+}
211211+```
212212+213213+Note: `Content` (full text) is included in the broadcast so receivers can sanity-check their state. The client applies deltas directly but falls back to full replacement if its local state diverges.
214214+215215+- [ ] **Step 6: Run tests to verify they pass**
216216+217217+```bash
218218+go test -v -race ./internal/collaboration/ -run TestClientMessage
219219+```
220220+221221+Expected: all 3 `TestClientMessage_*` tests PASS.
222222+223223+- [ ] **Step 7: Run full suite**
224224+225225+```bash
226226+go test -v -race ./internal/collaboration/
227227+```
228228+229229+Expected: all tests PASS (no regressions).
230230+231231+- [ ] **Step 8: Commit**
232232+233233+```bash
234234+git add internal/collaboration/client.go internal/collaboration/hub.go internal/collaboration/client_test.go
235235+git commit -m "feat: support multi-delta edit messages on server"
236236+```
237237+238238+---
239239+240240+## Chunk 2: Server — OT tests for multi-delta sequences
241241+242242+Before touching the frontend, verify the server applies multi-op sequences correctly.
243243+244244+### Task 2: Tests for `ApplyEdits` on `Room`
245245+246246+**Files:**
247247+- Modify: `internal/collaboration/hub_test.go`
248248+249249+- [ ] **Step 1: Write failing tests**
250250+251251+Add to `internal/collaboration/hub_test.go`:
252252+253253+```go
254254+func TestRoom_ApplyEdits_MultipleOpsAppliedInOrder(t *testing.T) {
255255+ hub := NewHub()
256256+ room := hub.GetOrCreateRoom("doc-multi-ops")
257257+258258+ alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi-ops")
259259+ bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-multi-ops")
260260+ room.RegisterClient(alice)
261261+ room.RegisterClient(bob)
262262+ time.Sleep(100 * time.Millisecond)
263263+ drain(alice, 200*time.Millisecond)
264264+ drain(bob, 200*time.Millisecond)
265265+266266+ // Two ops: set text to "hello", then append " world"
267267+ ops := []Operation{
268268+ {From: 0, To: -1, Insert: "hello"},
269269+ {From: 5, To: 5, Insert: " world"},
270270+ }
271271+ room.ApplyEdits(ops, alice)
272272+273273+ bobMsgs := waitForMessages(bob, 1, time.Second)
274274+ if len(bobMsgs) == 0 {
275275+ t.Fatal("bob: expected edit message")
276276+ }
277277+278278+ var msg struct {
279279+ Type string `json:"type"`
280280+ Deltas []Operation `json:"deltas"`
281281+ Content string `json:"content"`
282282+ Author string `json:"author"`
283283+ }
284284+ if err := json.Unmarshal(bobMsgs[0], &msg); err != nil {
285285+ t.Fatalf("unmarshal: %v", err)
286286+ }
287287+ if msg.Type != "edit" {
288288+ t.Errorf("type: got %q, want edit", msg.Type)
289289+ }
290290+ if msg.Content != "hello world" {
291291+ t.Errorf("content: got %q, want %q", msg.Content, "hello world")
292292+ }
293293+ if len(msg.Deltas) != 2 {
294294+ t.Errorf("deltas: got %d, want 2", len(msg.Deltas))
295295+ }
296296+ if msg.Author != "did:plc:alice" {
297297+ t.Errorf("author: got %q", msg.Author)
298298+ }
299299+}
300300+301301+func TestRoom_ApplyEdits_UpdatesOTState(t *testing.T) {
302302+ hub := NewHub()
303303+ room := hub.GetOrCreateRoom("doc-multi-ot")
304304+ alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi-ot")
305305+ room.RegisterClient(alice)
306306+ time.Sleep(100 * time.Millisecond)
307307+ drain(alice, 200*time.Millisecond)
308308+309309+ room.ApplyEdits([]Operation{
310310+ {From: 0, To: -1, Insert: "abc"},
311311+ {From: 3, To: 3, Insert: "def"},
312312+ }, alice)
313313+314314+ time.Sleep(50 * time.Millisecond)
315315+ if got := room.ot.GetText(); got != "abcdef" {
316316+ t.Errorf("OT state: got %q, want %q", got, "abcdef")
317317+ }
318318+}
319319+320320+func TestRoom_ApplyEdits_EmptyOpsIsNoop(t *testing.T) {
321321+ hub := NewHub()
322322+ room := hub.GetOrCreateRoom("doc-empty-ops")
323323+ alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-empty-ops")
324324+ bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-empty-ops")
325325+ room.RegisterClient(alice)
326326+ room.RegisterClient(bob)
327327+ time.Sleep(100 * time.Millisecond)
328328+ drain(alice, 200*time.Millisecond)
329329+ drain(bob, 200*time.Millisecond)
330330+331331+ room.ApplyEdits(nil, alice)
332332+333333+ bobMsgs := drain(bob, 300*time.Millisecond)
334334+ if len(bobMsgs) > 0 {
335335+ t.Errorf("expected no broadcast for empty ops, got %d messages", len(bobMsgs))
336336+ }
337337+}
338338+```
339339+340340+- [ ] **Step 2: Run tests to confirm they pass**
341341+342342+```bash
343343+go test -v ./internal/collaboration/ -run TestRoom_ApplyEdits
344344+```
345345+346346+Expected: PASS — `ApplyEdits` was added in Chunk 1 Task 1 Step 5.
347347+348348+- [ ] **Step 3: Run tests to verify they pass**
349349+350350+```bash
351351+go test -v -race ./internal/collaboration/ -run TestRoom_ApplyEdits
352352+```
353353+354354+Expected: all 3 tests PASS.
355355+356356+- [ ] **Step 4: Run full suite**
357357+358358+```bash
359359+go test -v -race ./internal/collaboration/
360360+```
361361+362362+Expected: all tests PASS.
363363+364364+- [ ] **Step 5: Commit**
365365+366366+```bash
367367+git add internal/collaboration/hub_test.go
368368+git commit -m "test: add multi-delta ApplyEdits tests"
369369+```
370370+371371+---
372372+373373+## Chunk 3: Frontend — CodeMirror granular deltas
374374+375375+CodeMirror 6's `update.changes` is a `ChangeSet`. Iterating it with `iterChanges` gives `(fromA, toA, fromB, toB, inserted)` — exactly the `{from, to, insert}` we need.
376376+377377+### Task 3: Send granular deltas from CodeMirror source mode
378378+379379+**Files:**
380380+- Modify: `templates/document_edit.html`
381381+382382+The change is in the `EditorView.updateListener.of(...)` callback and the `sendEdit` / new `sendDeltas` functions.
383383+384384+- [ ] **Step 1: Add `sendDeltas` function and debounce timer**
385385+386386+In `document_edit.html`, find the `sendEdit` function (around line 608) and replace it with:
387387+388388+```js
389389+ // Debounce timer for WebSocket sends (50ms batches rapid keystrokes).
390390+ let wsEditTimer = null;
391391+ let pendingDeltas = [];
392392+393393+ // Queue a set of deltas and flush after a short debounce.
394394+ function queueDeltas(deltas) {
395395+ if (!ws || ws.readyState !== WebSocket.OPEN || applyingRemote) return;
396396+ pendingDeltas = pendingDeltas.concat(deltas);
397397+ clearTimeout(wsEditTimer);
398398+ wsEditTimer = setTimeout(flushDeltas, 50);
399399+ }
400400+401401+ function flushDeltas() {
402402+ if (!ws || ws.readyState !== WebSocket.OPEN || pendingDeltas.length === 0) {
403403+ pendingDeltas = [];
404404+ return;
405405+ }
406406+ ws.send(JSON.stringify({ type: 'edit', deltas: pendingDeltas }));
407407+ pendingDeltas = [];
408408+ }
409409+410410+ // sendEdit is kept for any future callers; Milkdown switches to diffToOps
411411+ // in Chunk 4 and no longer calls this directly.
412412+ function sendEdit(content) {
413413+ if (!ws || ws.readyState !== WebSocket.OPEN || applyingRemote) return;
414414+ queueDeltas([{ from: 0, to: -1, insert: content }]);
415415+ }
416416+```
417417+418418+- [ ] **Step 2: Update the CodeMirror `updateListener` to use granular deltas**
419419+420420+Find the `EditorView.updateListener.of(...)` block (around line 180) and replace it:
421421+422422+```js
423423+ EditorView.updateListener.of((update) => {
424424+ if (update.docChanged && currentMode === 'source') {
425425+ const content = update.state.doc.toString();
426426+ updatePreview(content);
427427+ if (!update.transactions.some(tr => tr.annotation(remoteEditAnnotation))) {
428428+ scheduleAutoSave(content);
429429+ // Extract granular deltas from the ChangeSet.
430430+ const deltas = [];
431431+ update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
432432+ deltas.push({ from: fromA, to: toA, insert: inserted.toString() });
433433+ });
434434+ if (deltas.length > 0) {
435435+ queueDeltas(deltas);
436436+ }
437437+ }
438438+ }
439439+ }),
440440+```
441441+442442+Note: `fromA`/`toA` are positions in the **old** document (before the change), which is what the server's OT engine needs. `fromB`/`toB` are positions in the new document — not needed here.
443443+444444+- [ ] **Step 3: Update `applyRemoteEdit` to apply deltas when available**
445445+446446+Find `applyRemoteEdit` (around line 582) and replace:
447447+448448+```js
449449+ function applyRemoteEdit(msg) {
450450+ // msg may be a full-content string (legacy) or an object with deltas.
451451+ if (applyingRemote) return;
452452+ applyingRemote = true;
453453+ try {
454454+ // Determine content and deltas from the message.
455455+ let content = typeof msg === 'string' ? msg : msg.content;
456456+ const deltas = (typeof msg === 'object' && msg.deltas) ? msg.deltas : null;
457457+458458+ if (currentMode === 'source' && cmView) {
459459+ if (deltas && deltas.length > 0) {
460460+ // Apply each delta as a discrete CodeMirror change.
461461+ // Build changes array, adjusting positions for prior changes.
462462+ const docLen = cmView.state.doc.length;
463463+ const changes = deltas.map(d => ({
464464+ from: Math.min(d.from < 0 ? 0 : d.from, docLen),
465465+ to: Math.min(d.to < 0 ? docLen : d.to, docLen),
466466+ insert: d.insert || '',
467467+ }));
468468+ cmView.dispatch({
469469+ changes,
470470+ annotations: [remoteEditAnnotation.of(true)],
471471+ });
472472+ // Use the full-content echo from the server as a sanity-check.
473473+ if (content && cmView.state.doc.toString() !== content) {
474474+ // State diverged — fall back to full replacement.
475475+ cmView.dispatch({
476476+ changes: { from: 0, to: cmView.state.doc.length, insert: content },
477477+ annotations: [remoteEditAnnotation.of(true)],
478478+ });
479479+ }
480480+ } else if (content && cmView.state.doc.toString() !== content) {
481481+ // Legacy full-replacement path.
482482+ cmView.dispatch({
483483+ changes: { from: 0, to: cmView.state.doc.length, insert: content },
484484+ annotations: [remoteEditAnnotation.of(true)],
485485+ });
486486+ }
487487+ if (content) updatePreview(content);
488488+ } else if (currentMode === 'rich' && milkdownEditor && content) {
489489+ createMilkdownEditor(content);
490490+ }
491491+ } finally {
492492+ applyingRemote = false;
493493+ }
494494+ }
495495+```
496496+497497+- [ ] **Step 4: Update `handleWSMessage` to pass the full message object to `applyRemoteEdit`**
498498+499499+Find `handleWSMessage` (around line 567) and update the `'edit'` and `'sync'` cases:
500500+501501+```js
502502+ function handleWSMessage(msg) {
503503+ switch (msg.type) {
504504+ case 'presence':
505505+ updatePresence(msg.users || []);
506506+ break;
507507+ case 'pong':
508508+ wsMissedPings = 0;
509509+ break;
510510+ case 'edit':
511511+ applyRemoteEdit(msg); // pass full message object, not just msg.content
512512+ break;
513513+ case 'sync':
514514+ applyRemoteEdit(msg.content); // sync is always full-content
515515+ break;
516516+ }
517517+ }
518518+```
519519+520520+- [ ] **Step 5: Manual smoke test**
521521+522522+```bash
523523+# In the collaboration worktree:
524524+make run
525525+```
526526+527527+1. Open two browser tabs, both logged in as different ATProto users on the same document.
528528+2. Type in tab A (source mode) — tab B should receive and apply the edit without full-page flicker.
529529+3. Type simultaneously — verify no echo loop (your own edits don't come back).
530530+4. Switch tab A to rich mode, type — tab B should still update (via legacy full-replacement path).
531531+5. Open browser devtools → Network → WS — verify messages are small (just the changed characters) not full document.
532532+533533+- [ ] **Step 6: Commit**
534534+535535+```bash
536536+git add templates/document_edit.html
537537+git commit -m "feat: send granular CodeMirror deltas over WebSocket with 50ms debounce"
538538+```
539539+540540+---
541541+542542+## Chunk 4: Frontend — Milkdown string-diff deltas
543543+544544+Milkdown only exposes the complete new markdown string. We compute a Myers diff between the previous and current string to produce `{from, to, insert}` operations.
545545+546546+### Task 4: Inline Myers diff and wire up Milkdown listener
547547+548548+**Files:**
549549+- Modify: `templates/document_edit.html`
550550+551551+The Myers diff algorithm produces the minimal edit script between two strings. We inline a small implementation rather than adding an npm dependency, keeping the bundle unchanged.
552552+553553+- [ ] **Step 1: Add inline Myers diff function**
554554+555555+Just before the `queueDeltas` function added in Task 3, insert:
556556+557557+```js
558558+ /**
559559+ * Compute the minimal edit operations to transform `oldStr` into `newStr`.
560560+ * Returns an array of {from, to, insert} suitable for the OT engine.
561561+ *
562562+ * Uses a line-level diff for performance, then falls back to a single
563563+ * full-replacement op if the diff produces more than 20 operations
564564+ * (pathological case — not worth the complexity).
565565+ */
566566+ function diffToOps(oldStr, newStr) {
567567+ if (oldStr === newStr) return [];
568568+569569+ const oldLines = oldStr.split('\n');
570570+ const newLines = newStr.split('\n');
571571+572572+ // Build line-level LCS table (Myers / Wagner-Fischer).
573573+ const m = oldLines.length, n = newLines.length;
574574+ const dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0));
575575+ for (let i = m - 1; i >= 0; i--) {
576576+ for (let j = n - 1; j >= 0; j--) {
577577+ dp[i][j] = oldLines[i] === newLines[j]
578578+ ? dp[i+1][j+1] + 1
579579+ : Math.max(dp[i+1][j], dp[i][j+1]);
580580+ }
581581+ }
582582+583583+ // Trace back to produce diff hunks.
584584+ const ops = [];
585585+ let i = 0, j = 0;
586586+ // Track character offset into oldStr.
587587+ let charOffset = 0;
588588+ // +1 for the \n separator. The last line has no trailing \n, so its
589589+ // length is exact; however the OT engine clamps out-of-range positions,
590590+ // so an off-by-one on the final line is safe in practice.
591591+ const oldLineLengths = oldLines.map(l => l.length + 1);
592592+593593+ while (i < m || j < n) {
594594+ if (i < m && j < n && oldLines[i] === newLines[j]) {
595595+ charOffset += oldLineLengths[i];
596596+ i++; j++;
597597+ } else if (j < n && (i >= m || dp[i+1][j] <= dp[i][j+1])) {
598598+ // Insert newLines[j]
599599+ const insertText = newLines[j] + (j < n - 1 ? '\n' : '');
600600+ ops.push({ from: charOffset, to: charOffset, insert: insertText });
601601+ j++;
602602+ } else {
603603+ // Delete oldLines[i]
604604+ const deleteLen = oldLineLengths[i];
605605+ ops.push({ from: charOffset, to: charOffset + deleteLen, insert: '' });
606606+ charOffset += deleteLen;
607607+ i++;
608608+ }
609609+ }
610610+611611+ // Fallback: if diff is too fragmented, send a single full replacement.
612612+ if (ops.length > 20) {
613613+ return [{ from: 0, to: -1, insert: newStr }];
614614+ }
615615+616616+ return ops;
617617+ }
618618+```
619619+620620+- [ ] **Step 2: Update the Milkdown `markdownUpdated` listener to use `diffToOps`**
621621+622622+Find the Milkdown listener (around line 246):
623623+624624+```js
625625+ ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => {
626626+ if (markdown !== prevMarkdown && !applyingRemote) {
627627+ scheduleAutoSave(markdown);
628628+ sendEdit(markdown);
629629+ }
630630+ });
631631+```
632632+633633+Replace with:
634634+635635+```js
636636+ ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => {
637637+ if (markdown !== prevMarkdown && !applyingRemote) {
638638+ scheduleAutoSave(markdown);
639639+ const ops = diffToOps(prevMarkdown || '', markdown);
640640+ if (ops.length > 0) {
641641+ queueDeltas(ops);
642642+ }
643643+ }
644644+ });
645645+```
646646+647647+- [ ] **Step 3: Manual smoke test for rich text mode**
648648+649649+```bash
650650+make run
651651+```
652652+653653+1. Open two browser tabs on the same document, both in **rich** mode.
654654+2. Type in tab A — tab B should update.
655655+3. In devtools → Network → WS: verify messages contain `deltas` arrays (not a single large `insert`).
656656+4. Make a large edit (paste a paragraph) — verify fallback fires (single full-replacement op) rather than 20+ tiny ops.
657657+658658+- [ ] **Step 4: Commit**
659659+660660+```bash
661661+git add templates/document_edit.html
662662+git commit -m "feat: diff-based granular deltas for Milkdown rich text mode"
663663+```
664664+665665+---
666666+667667+## Chunk 5: Cleanup and edge cases
668668+669669+### Task 5: Handle pending deltas on WebSocket close
670670+671671+If the browser tab closes or the WebSocket drops while `pendingDeltas` is non-empty, those changes are lost silently. Flush synchronously on close.
672672+673673+**Files:**
674674+- Modify: `templates/document_edit.html`
675675+676676+- [ ] **Step 1: Extract `closeWS` helper and wire it up**
677677+678678+There is no named `closeWS` function in the current code. `ws.close()` is called in two places:
679679+- `ws.onerror` callback (around line 533)
680680+- Inside `startHeartbeat`'s ping timer when missed pings ≥ 3 (around line 553)
681681+682682+Replace both call sites with a shared `closeWS` helper. Add the helper alongside `flushDeltas`:
683683+684684+```js
685685+ function closeWS() {
686686+ if (!ws) return;
687687+ clearTimeout(wsEditTimer);
688688+ flushDeltas(); // send any buffered deltas before closing
689689+ ws.close();
690690+ ws = null;
691691+ clearInterval(wsPingTimer);
692692+ }
693693+```
694694+695695+Then update `ws.onerror`:
696696+```js
697697+ ws.onerror = () => {
698698+ closeWS();
699699+ };
700700+```
701701+702702+And update the heartbeat missed-ping branch:
703703+```js
704704+ if (wsMissedPings >= 3) {
705705+ closeWS();
706706+ }
707707+```
708708+709709+Also update `ws.onclose` to call `stopHeartbeat()` only (not `clearInterval` directly, since `closeWS` handles that when called from onerror/heartbeat; onclose fires for all close paths and should remain lightweight):
710710+```js
711711+ ws.onclose = () => {
712712+ ws = null;
713713+ updatePresence([]);
714714+ scheduleReconnect();
715715+ };
716716+```
717717+718718+- [ ] **Step 2: Flush on page unload**
719719+720720+Add a `beforeunload` handler at the end of the `{{define "scripts"}}` block, after the `connectWebSocket()` call:
721721+722722+```js
723723+ window.addEventListener('beforeunload', () => {
724724+ clearTimeout(wsEditTimer);
725725+ flushDeltas();
726726+ });
727727+```
728728+729729+- [ ] **Step 3: Commit**
730730+731731+```bash
732732+git add templates/document_edit.html
733733+git commit -m "fix: flush pending WS deltas on close and page unload"
734734+```
735735+736736+### Task 6: OT engine — add `SetText` for room initialisation
737737+738738+Currently `GetOrCreateRoom` initialises the OT engine with `""`. When a room is first created, the server fetches the document but doesn't seed the engine. If two users join simultaneously, the first edit from user A resets the OT state, which is fine for the current full-replacement protocol but wrong for granular deltas (user B's deltas will have stale offsets).
739739+740740+**Files:**
741741+- Modify: `internal/collaboration/ot.go`
742742+- Modify: `internal/collaboration/hub.go`
743743+- Modify: `internal/handler/handler.go`
744744+- Test: `internal/collaboration/ot_test.go`
745745+746746+- [ ] **Step 1: Write failing test for `SetText`**
747747+748748+Add to `internal/collaboration/ot_test.go`:
749749+750750+```go
751751+func TestOTEngine_SetText(t *testing.T) {
752752+ ot := NewOTEngine("")
753753+ ot.SetText("initial content")
754754+ if ot.GetText() != "initial content" {
755755+ t.Errorf("SetText: got %q, want %q", ot.GetText(), "initial content")
756756+ }
757757+ // SetText should not change the version counter.
758758+ if ot.GetVersion() != 0 {
759759+ t.Errorf("SetText should not increment version: got %d", ot.GetVersion())
760760+ }
761761+}
762762+```
763763+764764+- [ ] **Step 2: Run test to verify it fails**
765765+766766+```bash
767767+go test -v ./internal/collaboration/ -run TestOTEngine_SetText
768768+```
769769+770770+Expected: FAIL — `SetText` undefined.
771771+772772+- [ ] **Step 3: Add `SetText` to `internal/collaboration/ot.go`**
773773+774774+```go
775775+// SetText replaces the canonical document text without incrementing the version.
776776+// Call this once when a room is first created, before any edits are applied.
777777+func (ot *OTEngine) SetText(text string) {
778778+ ot.mu.Lock()
779779+ defer ot.mu.Unlock()
780780+ ot.documentText = text
781781+}
782782+```
783783+784784+- [ ] **Step 4: Run test to verify it passes**
785785+786786+```bash
787787+go test -v -race ./internal/collaboration/ -run TestOTEngine_SetText
788788+```
789789+790790+Expected: PASS.
791791+792792+- [ ] **Step 5: Seed the OT engine in `CollaboratorWebSocket` handler**
793793+794794+In `internal/handler/handler.go`, find `CollaboratorWebSocket` (search for `GetOrCreateRoom`). After fetching the document and before registering the client, seed the room's OT engine if it's a new room:
795795+796796+```go
797797+ room := h.CollaborationHub.GetOrCreateRoom(rKey)
798798+ // Seed OT engine with the current document text on first join.
799799+ // GetOrCreateRoom returns existing rooms unchanged — SetText is idempotent
800800+ // only if called before the first edit, which is guaranteed here because
801801+ // we check IsNewRoom() before seeding.
802802+ if room.IsNewRoom() {
803803+ var initialText string
804804+ if doc.Content != nil {
805805+ initialText = doc.Content.Text.RawMarkdown
806806+ }
807807+ room.SeedText(initialText)
808808+ }
809809+```
810810+811811+This requires adding `IsNewRoom()` and `SeedText()` to `Room`.
812812+813813+- [ ] **Step 6: Add `IsNewRoom` and `SeedText` to `Room` in `internal/collaboration/hub.go`**
814814+815815+Add a `seeded` field to `Room`:
816816+817817+```go
818818+type Room struct {
819819+ documentRKey string
820820+ clients map[*Client]bool
821821+ broadcast chan *broadcastMsg
822822+ register chan *Client
823823+ unregister chan *Client
824824+ mu sync.RWMutex
825825+ ot *OTEngine
826826+ seeded bool // true after SeedText has been called
827827+}
828828+```
829829+830830+Add methods:
831831+832832+```go
833833+// IsNewRoom returns true if SeedText has not yet been called on this room.
834834+func (r *Room) IsNewRoom() bool {
835835+ r.mu.RLock()
836836+ defer r.mu.RUnlock()
837837+ return !r.seeded
838838+}
839839+840840+// SeedText sets the initial document text for the OT engine.
841841+// Safe to call only before the first edit is applied.
842842+func (r *Room) SeedText(text string) {
843843+ r.mu.Lock()
844844+ defer r.mu.Unlock()
845845+ if !r.seeded {
846846+ r.ot.SetText(text)
847847+ r.seeded = true
848848+ }
849849+}
850850+```
851851+852852+- [ ] **Step 7: Write test for `SeedText`**
853853+854854+Add to `internal/collaboration/hub_test.go`:
855855+856856+```go
857857+func TestRoom_SeedText_SetsInitialOTState(t *testing.T) {
858858+ hub := NewHub()
859859+ room := hub.GetOrCreateRoom("doc-seed")
860860+ if !room.IsNewRoom() {
861861+ t.Fatal("new room should report IsNewRoom=true")
862862+ }
863863+ room.SeedText("initial document content")
864864+ if room.IsNewRoom() {
865865+ t.Error("IsNewRoom should be false after seeding")
866866+ }
867867+ if got := room.ot.GetText(); got != "initial document content" {
868868+ t.Errorf("OT text after seed: got %q, want %q", got, "initial document content")
869869+ }
870870+}
871871+872872+func TestRoom_SeedText_IdempotentAfterFirstCall(t *testing.T) {
873873+ hub := NewHub()
874874+ room := hub.GetOrCreateRoom("doc-seed-idem")
875875+ room.SeedText("first")
876876+ room.SeedText("second") // should be ignored
877877+ if got := room.ot.GetText(); got != "first" {
878878+ t.Errorf("second SeedText should be ignored: got %q, want %q", got, "first")
879879+ }
880880+}
881881+```
882882+883883+- [ ] **Step 8: Run all tests**
884884+885885+```bash
886886+go test -v -race ./internal/collaboration/
887887+```
888888+889889+Expected: all tests PASS.
890890+891891+- [ ] **Step 9: Commit**
892892+893893+```bash
894894+git add internal/collaboration/ot.go internal/collaboration/hub.go \
895895+ internal/collaboration/ot_test.go internal/collaboration/hub_test.go \
896896+ internal/handler/handler.go
897897+git commit -m "feat: seed OT engine with document text on room creation"
898898+```
899899+900900+---
901901+902902+## Chunk 6: Final verification
903903+904904+- [ ] **Run full test suite with race detector**
905905+906906+```bash
907907+go test -v -race ./...
908908+```
909909+910910+Expected: all tests PASS, no race conditions.
911911+912912+- [ ] **Build**
913913+914914+```bash
915915+make build
916916+```
917917+918918+Expected: builds cleanly, no warnings.
919919+920920+- [ ] **Lint**
921921+922922+```bash
923923+make lint
924924+```
925925+926926+Expected: no lint errors.
927927+928928+- [ ] **End-to-end smoke test**
929929+930930+```bash
931931+make run
932932+```
933933+934934+1. Two users, same document, source mode: type rapidly — verify small WS messages (devtools → Network → WS frames).
935935+2. Two users, same document, rich mode: type — verify updates propagate correctly.
936936+3. Disconnect one user mid-edit — verify reconnect gets `sync` message with correct state.
937937+4. Empty the document entirely — verify the other user sees an empty document (tests the `Insert=""` deletion fix).
938938+939939+- [ ] **Commit any fixups, then push**
940940+941941+```bash
942942+git push origin feature/collaboration
943943+```
···11+-- 005_create_invites.sql
22+33+CREATE TABLE IF NOT EXISTS invites (
44+ id TEXT PRIMARY KEY,
55+ document_rkey TEXT NOT NULL,
66+ token TEXT NOT NULL UNIQUE,
77+ created_by_did TEXT NOT NULL,
88+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
99+ expires_at DATETIME NOT NULL
1010+);
1111+1212+CREATE INDEX IF NOT EXISTS idx_invites_document ON invites(document_rkey);
1313+CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);
···22{{define "content"}}
33<div class="landing">
44 <h1>Collaborative Markdown Editing</h1>
55- <p>Write, review, and collaborate on Markdown documents with your team. Uh, eventually. Right now, you can only write and edit solo documents.</p>
66- <p><strong>Note:</strong> This app is a toy I built to learn <a href="https://atproto.com/">AT Protocol</a>, lexicons, and <a href="https://claude.com/">Claude Code</a>. It is not meant for actual use. Any documents you create will be visible to anyone with the URL and may be deleted at any time.</p>
55+ <p>Write, review, and collaborate on Markdown documents with your team in <a href="https://www.bskyinfo.com/glossary/atmosphere/">the ATmosphere</a>.</p>
66+ <p><strong>Note:</strong> This app is a toy I built to learn <a href="https://atproto.com/">AT Protocol</a>, lexicons, and <a href="https://agentic-coding.github.io/">agentic coding</a>. It is not meant for actual use. Any documents you create will be visible to anyone with the URL and may be deleted at any time.</p>
77 <div class="landing-actions">
88 <a href="/auth/register" class="btn btn-lg">Get Started</a>
99 <a href="/auth/login" class="btn btn-lg btn-outline">Log In</a>