# Embedded Comments Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Replace the broken per-commenter ATProto record approach with comments embedded directly in the `com.diffdown.document` record on the owner's PDS. Comments become a portable part of the document. **Architecture:** `Document.Comments []EmbeddedComment` is stored inside the ATProto record. `CommentCreate` does read-modify-write on the owner's document via the owner's XRPC client (same pattern as `AutoSave`). `CommentList` reads the document and returns the embedded array. The collaborator passes `ownerDID` in the request so the handler knows whose PDS to talk to. **Tech Stack:** Go 1.22, ATProto XRPC, html/template, ProseMirror (Milkdown) click events --- ### Task 1: Update the Document model **Files:** - Modify: `internal/model/models.go` **Step 1: Add `EmbeddedComment` struct and `Comments` field to `Document`** Add after the `MarkdownText` struct: ```go type EmbeddedComment struct { ID string `json:"id"` // random 8-char hex for dedup ParagraphID string `json:"paragraphId"` // "p-0", "p-1", ... or "general" Text string `json:"text"` Author string `json:"author"` // DID AuthorHandle string `json:"authorHandle"` // resolved handle, may be empty CreatedAt string `json:"createdAt"` // RFC3339 } ``` Add `Comments []EmbeddedComment` to the `Document` struct, after `Collaborators`: ```go Comments []EmbeddedComment `json:"comments,omitempty"` ``` Keep the existing `Comment` struct for now — it will be removed in Task 2. **Step 2: Build** ```bash go build ./... ``` Expected: PASS. **Step 3: Commit** ```bash git add internal/model/models.go git commit -m "Add EmbeddedComment type and Comments field to Document" ``` --- ### Task 2: Remove old XRPC comment methods and stale types **Files:** - Modify: `internal/atproto/xrpc/client.go` - Modify: `internal/model/models.go` - Modify: `internal/handler/handler.go` **Step 1: Delete from `internal/atproto/xrpc/client.go`** Remove the `collectionComment` const (line 317) and the `CreateComment` and `ListComments` functions (lines 319-357). Check whether `strings` import is still needed elsewhere before removing it: ```bash grep -n 'strings\.' internal/atproto/xrpc/client.go ``` Remove unused import if needed. **Step 2: Delete `collectionComment` const from `internal/handler/handler.go`** Line 27: `const collectionComment = "com.diffdown.comment"` — delete it. **Step 3: Remove the old `Comment` struct from `internal/model/models.go`** Delete the `Comment` struct block. **Step 4: Build — expect errors** ```bash go build ./... ``` Expected: compile errors about `CommentCreate` and `ListComments` called in handler. These are fixed in Task 3 — do not commit yet. --- ### Task 3: Rewrite `CommentCreate` and `CommentList` handlers **Files:** - Modify: `internal/handler/handler.go` **Step 1: Add `crypto/rand` and `encoding/hex` imports** In the `import` block, add both if not already present. **Step 2: Add `randomID` helper near the top of handler.go (after imports)** ```go func randomID() string { b := make([]byte, 4) rand.Read(b) return hex.EncodeToString(b) } ``` **Step 3: Rewrite `CommentCreate`** Replace the entire function with: ```go func (h *Handler) CommentCreate(w http.ResponseWriter, r *http.Request) { user, _ := h.currentUser(r) if user == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } rKey := r.PathValue("rkey") if rKey == "" { http.Error(w, "Invalid document", http.StatusBadRequest) return } var req struct { OwnerDID string `json:"ownerDID"` ParagraphID string `json:"paragraphId"` Text string `json:"text"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.Text == "" { http.Error(w, "Comment text required", http.StatusBadRequest) return } session, err := h.DB.GetATProtoSession(user.ID) if err != nil || session == nil { http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) return } ownerUserID := user.ID ownerDID := session.DID if req.OwnerDID != "" && req.OwnerDID != session.DID { ownerUser, err := h.DB.GetUserByDID(req.OwnerDID) if err != nil { http.Error(w, "Owner not found", http.StatusBadRequest) return } ownerUserID = ownerUser.ID ownerDID = req.OwnerDID } ownerClient, err := h.xrpcClient(ownerUserID) if err != nil { http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) return } value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) if err != nil { log.Printf("CommentCreate: GetRecord: %v", err) http.Error(w, "Document not found", http.StatusNotFound) return } var doc model.Document if err := json.Unmarshal(value, &doc); err != nil { http.Error(w, "Failed to parse document", http.StatusInternalServerError) return } authorHandle, _ := atproto.ResolveHandleFromDID(session.DID) paragraphID := req.ParagraphID if paragraphID == "" { paragraphID = "general" } comment := model.EmbeddedComment{ ID: randomID(), ParagraphID: paragraphID, Text: req.Text, Author: session.DID, AuthorHandle: authorHandle, CreatedAt: time.Now().UTC().Format(time.RFC3339), } doc.Comments = append(doc.Comments, comment) if _, _, err := ownerClient.PutDocument(rKey, &doc); err != nil { log.Printf("CommentCreate: PutDocument: %v", err) http.Error(w, "Failed to save comment", http.StatusInternalServerError) return } h.jsonResponse(w, comment, http.StatusCreated) } ``` **Step 4: Rewrite `CommentList`** Replace the entire function with: ```go func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { rKey := r.PathValue("rkey") if rKey == "" { http.Error(w, "Invalid document", http.StatusBadRequest) return } user, _ := h.currentUser(r) if user == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } session, err := h.DB.GetATProtoSession(user.ID) if err != nil || session == nil { http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) return } ownerUserID := user.ID ownerDID := session.DID if qOwner := r.URL.Query().Get("ownerDID"); qOwner != "" && qOwner != session.DID { ownerUser, err := h.DB.GetUserByDID(qOwner) if err != nil { http.Error(w, "Owner not found", http.StatusBadRequest) return } ownerUserID = ownerUser.ID ownerDID = qOwner } ownerClient, err := h.xrpcClient(ownerUserID) if err != nil { http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) return } value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) if err != nil { log.Printf("CommentList: GetRecord: %v", err) http.Error(w, "Document not found", http.StatusNotFound) return } var doc model.Document if err := json.Unmarshal(value, &doc); err != nil { http.Error(w, "Failed to parse document", http.StatusInternalServerError) return } comments := doc.Comments if comments == nil { comments = []model.EmbeddedComment{} } h.jsonResponse(w, comments, http.StatusOK) } ``` **Step 5: Build** ```bash go build ./... ``` Expected: PASS. **Step 6: Run tests** ```bash go test ./... ``` Expected: all pass. **Step 7: Commit** ```bash git add internal/handler/handler.go internal/model/models.go internal/atproto/xrpc/client.go git commit -m "Embed comments in document record; rewrite CommentCreate/CommentList" ``` --- ### Task 4: Fix comment sidebar visibility (show for owners too) **Files:** - Modify: `templates/document_edit.html` **Step 1: Change the sidebar guard from `IsCollaborator` to `or .IsCollaborator .IsOwner`** Find line ~85: ``` {{if .IsCollaborator}} ``` above the `comment-sidebar` div. Change to: ``` {{if or .IsCollaborator .IsOwner}} ``` **Step 2: Build** ```bash go build ./... ``` **Step 3: Commit** ```bash git add templates/document_edit.html git commit -m "Show comment sidebar for document owners, not just collaborators" ``` --- ### Task 5: Wire up paragraph click + update API calls **Files:** - Modify: `templates/document_edit.html` - Modify: `static/css/editor.css` **Step 1: Update `submitComment` to include `ownerDID` in POST body** The template already has `const ownerDID = '{{.Content.OwnerDID}}'`. In `submitComment` (around line 791), the fetch body currently sends: ```js { paragraphId: activeCommentParagraphId, text } ``` Change it to build an object, add `ownerDID` if non-empty, then send: ```js const body = { paragraphId: activeCommentParagraphId, text }; if (ownerDID) body.ownerDID = ownerDID; // then JSON.stringify(body) ``` **Step 2: Update `loadComments` to pass `ownerDID` query param** The fetch URL is currently `/api/docs/${rkey}/comments`. Change to append `?ownerDID=...` when `ownerDID` is set: ```js const qs = ownerDID ? '?ownerDID=' + encodeURIComponent(ownerDID) : ''; // then fetch(`/api/docs/${rkey}/comments${qs}`) ``` **Step 3: Add paragraph click detection after `loadComments` function** After the `loadComments` function, add a new function `setupParagraphCommentTrigger` that: 1. Finds `#editor-rich` (the Milkdown container) 2. Listens for `click` events 3. Calls `e.target.closest('.ProseMirror')` to confirm click is inside the editor 4. Calls `e.target.closest('p, h1, h2, h3, h4, h5, h6, li')` to find the paragraph 5. Computes index: `Array.from(paraEl.parentElement.children).indexOf(paraEl)` — yields 0-based index 6. Sets `activeCommentParagraphId = 'p-' + idx` 7. Positions and shows `commentBtn` to the right of the paragraph using `getBoundingClientRect()` + `window.scrollY` Call `setupParagraphCommentTrigger()` immediately after defining it. **Step 4: Add/verify `.comment-btn` CSS in `static/css/editor.css`** Check whether `.comment-btn` has `position: fixed` or `position: absolute` and `z-index`. If not present, add: ```css .comment-btn { position: fixed; z-index: 100; } ``` **Step 5: Build** ```bash go build ./... ``` **Step 6: Commit** ```bash git add templates/document_edit.html static/css/editor.css git commit -m "Wire paragraph click to comment button; pass ownerDID in comment API calls" ``` --- ### Task 6: Improve comment sidebar rendering **Files:** - Modify: `templates/document_edit.html` **Step 1: Update `renderCommentThreads` paragraph label** In the `renderCommentThreads` function, the thread label is currently: ``` ¶ ${paragraphId} ``` Change to compute a human-readable label before building the thread HTML: ```js const label = pid === 'general' ? 'General' : 'Paragraph ' + (parseInt(pid.replace('p-', ''), 10) + 1); ``` Then use `label` in the label div text content (via `escHtml(label)`). **Step 2: Update author display to prefer `authorHandle`** The comment item shows `c.authorName || c.author`. Since the new struct uses `authorHandle`, update to `c.authorHandle || c.author`. **Step 3: Build and run all tests** ```bash go build ./... && go test ./... ``` Expected: PASS — 3 test packages, all green. **Step 4: Commit** ```bash git add templates/document_edit.html git commit -m "Improve comment rendering: human-readable paragraph labels, authorHandle" ``` --- ### Task 7: Manual smoke test **Start the server from the worktree:** ```bash go run ./cmd/server ``` 1. **Owner:** Sign in, open a document. Click a paragraph body — the blue "Comment" button should appear to the right. Click it, write a comment, post. The sidebar appears with the comment under "Paragraph N". 2. **Collaborator:** Sign in as the collaborator, navigate to the shared doc via `/docs/{ownerDID}/{rkey}`. Click a paragraph, post a comment. The comment appears in the sidebar with your handle. 3. **Owner sees collaborator's comment:** Sign back in as owner on the same document — comment is visible in the sidebar. --- ## Notes - **No DB migration needed** — comments live entirely in the ATProto record. - **Concurrent comment race:** `CommentCreate` reads then writes; two concurrent comments could overwrite each other (last write wins). Acceptable trade-off; CID-based optimistic locking is a future improvement. - **Positional paragraph IDs:** `p-0`, `p-1` are index-based. Document edits may shift which content a comment refers to. Stable UUIDs embedded in ProseMirror schema would require a bigger refactor. - **Old `com.diffdown.comment` records:** Orphaned on user PDSes. Cleanup is out of scope.