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:
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:
Comments []EmbeddedComment `json:"comments,omitempty"`
Keep the existing Comment struct for now — it will be removed in Task 2.
Step 2: Build
go build ./...
Expected: PASS.
Step 3: Commit
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:
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
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)
func randomID() string {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}
Step 3: Rewrite CommentCreate
Replace the entire function with:
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:
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
go build ./...
Expected: PASS.
Step 6: Run tests
go test ./...
Expected: all pass.
Step 7: Commit
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
go build ./...
Step 3: Commit
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:
{ paragraphId: activeCommentParagraphId, text }
Change it to build an object, add ownerDID if non-empty, then send:
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:
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:
- Finds
#editor-rich(the Milkdown container) - Listens for
clickevents - Calls
e.target.closest('.ProseMirror')to confirm click is inside the editor - Calls
e.target.closest('p, h1, h2, h3, h4, h5, h6, li')to find the paragraph - Computes index:
Array.from(paraEl.parentElement.children).indexOf(paraEl)— yields 0-based index - Sets
activeCommentParagraphId = 'p-' + idx - Positions and shows
commentBtnto the right of the paragraph usinggetBoundingClientRect()+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:
.comment-btn {
position: fixed;
z-index: 100;
}
Step 5: Build
go build ./...
Step 6: Commit
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:
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
go build ./... && go test ./...
Expected: PASS — 3 test packages, all green.
Step 4: Commit
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:
go run ./cmd/server
-
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".
-
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. -
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:
CommentCreatereads 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-1are 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.commentrecords: Orphaned on user PDSes. Cleanup is out of scope.