From 85f95c4542d3400b2e0982b2b256d30193776514 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 11:02:22 -0600 Subject: [PATCH] Add permalink anchors to comments and GET comment-by-ID API - Comment dates are now clickable anchor links (#comment-{id}) - Pages scroll to and highlight the target comment when URL has a hash - Added GET /api/issues/:id/comments/:commentId endpoint - Updated skill docs with new endpoint and comment URL format Co-Authored-By: Claude Opus 4.6 --- server/src/routes/issues.ts | 17 ++++++++ server/src/services/issues.ts | 7 ++++ skills/paperclip/SKILL.md | 2 + skills/paperclip/references/api-reference.md | 1 + ui/src/components/CommentThread.tsx | 41 +++++++++++++++++--- 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 67b43c04..824493f4 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -698,6 +698,23 @@ export function issueRoutes(db: Db, storage: StorageService) { res.json(comments); }); + router.get("/issues/:id/comments/:commentId", async (req, res) => { + const id = req.params.id as string; + const commentId = req.params.commentId as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const comment = await svc.getComment(commentId); + if (!comment || comment.issueId !== id) { + res.status(404).json({ error: "Comment not found" }); + return; + } + res.json(comment); + }); + router.post("/issues/:id/comments", validate(addIssueCommentSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 5a886980..33d8d130 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -779,6 +779,13 @@ export function issueService(db: Db) { .where(eq(issueComments.issueId, issueId)) .orderBy(desc(issueComments.createdAt)), + getComment: (commentId: string) => + db + .select() + .from(issueComments) + .where(eq(issueComments.id, commentId)) + .then((rows) => rows[0] ?? null), + addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => { const issue = await db .select({ companyId: issues.companyId }) diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 9d32ac80..b42355d9 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -117,6 +117,7 @@ When posting issue comments, use concise markdown with: **Company-prefixed URLs (required):** All internal links MUST include the company prefix. Derive the prefix from any issue identifier you have (e.g., `PAP-315` → prefix is `PAP`). Use this prefix in all UI links: - Issues: `//issues/` (e.g., `/PAP/issues/PAP-224`) +- Issue comments: `//issues/#comment-` (deep link to a specific comment) - Agents: `//agents/` (e.g., `/PAP/agents/claudecoder`) - Projects: `//projects/` (id fallback allowed) - Approvals: `//approvals/` @@ -199,6 +200,7 @@ PATCH /api/agents/{agentId}/instructions-path | Checkout task | `POST /api/issues/:issueId/checkout` | | Get task + ancestors | `GET /api/issues/:issueId` | | Get comments | `GET /api/issues/:issueId/comments` | +| Get specific comment | `GET /api/issues/:issueId/comments/:commentId` | | Update task | `PATCH /api/issues/:issueId` (optional `comment` field) | | Add comment | `POST /api/issues/:issueId/comments` | | Create subtask | `POST /api/companies/:companyId/issues` | diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 4dc3c414..a88abb82 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -481,6 +481,7 @@ Terminal states: `done`, `cancelled` | POST | `/api/issues/:issueId/checkout` | Atomic checkout (claim + start). Idempotent if you already own it. | | POST | `/api/issues/:issueId/release` | Release task ownership | | GET | `/api/issues/:issueId/comments` | List comments | +| GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID | | POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) | | GET | `/api/issues/:issueId/approvals` | List approvals linked to issue | | POST | `/api/issues/:issueId/approvals` | Link approval to issue | diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index b1c2271a..b96c1af0 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; -import { Link } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; import type { IssueComment, Agent } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; import { Paperclip } from "lucide-react"; @@ -98,9 +98,11 @@ type TimelineItem = const TimelineList = memo(function TimelineList({ timeline, agentMap, + highlightCommentId, }: { timeline: TimelineItem[]; agentMap?: Map; + highlightCommentId?: string | null; }) { if (timeline.length === 0) { return

No comments or runs yet.

; @@ -139,8 +141,13 @@ const TimelineList = memo(function TimelineList({ } const comment = item.comment; + const isHighlighted = highlightCommentId === comment.id; return ( -
+
{comment.authorAgentId ? ( @@ -152,9 +159,12 @@ const TimelineList = memo(function TimelineList({ ) : ( )} - + {formatDateTime(comment.createdAt)} - +
{comment.body} {comment.runId && ( @@ -200,9 +210,12 @@ export function CommentThread({ const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); const [reassignTarget, setReassignTarget] = useState(currentAssigneeValue); + const [highlightCommentId, setHighlightCommentId] = useState(null); const editorRef = useRef(null); const attachInputRef = useRef(null); const draftTimer = useRef | null>(null); + const location = useLocation(); + const hasScrolledRef = useRef(false); const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false; @@ -261,6 +274,24 @@ export function CommentThread({ setReassignTarget(currentAssigneeValue); }, [currentAssigneeValue]); + // Scroll to comment when URL hash matches #comment-{id} + useEffect(() => { + const hash = location.hash; + if (!hash.startsWith("#comment-") || comments.length === 0) return; + const commentId = hash.slice("#comment-".length); + // Only scroll once per hash + if (hasScrolledRef.current) return; + const el = document.getElementById(`comment-${commentId}`); + if (el) { + hasScrolledRef.current = true; + setHighlightCommentId(commentId); + el.scrollIntoView({ behavior: "smooth", block: "center" }); + // Clear highlight after animation + const timer = setTimeout(() => setHighlightCommentId(null), 3000); + return () => clearTimeout(timer); + } + }, [location.hash, comments]); + async function handleSubmit() { const trimmed = body.trim(); if (!trimmed) return; @@ -297,7 +328,7 @@ export function CommentThread({

Comments & Runs ({timeline.length})

- + {liveRunSlot}