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 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-03-05 11:02:22 -06:00
parent 732ae4e46c
commit 85f95c4542
5 changed files with 63 additions and 5 deletions

View File

@@ -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);

View File

@@ -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 })

View File

@@ -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: `/<prefix>/issues/<issue-identifier>` (e.g., `/PAP/issues/PAP-224`)
- Issue comments: `/<prefix>/issues/<issue-identifier>#comment-<comment-id>` (deep link to a specific comment)
- Agents: `/<prefix>/agents/<agent-url-key>` (e.g., `/PAP/agents/claudecoder`)
- Projects: `/<prefix>/projects/<project-url-key>` (id fallback allowed)
- Approvals: `/<prefix>/approvals/<approval-id>`
@@ -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` |

View File

@@ -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 |

View File

@@ -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<string, Agent>;
highlightCommentId?: string | null;
}) {
if (timeline.length === 0) {
return <p className="text-sm text-muted-foreground">No comments or runs yet.</p>;
@@ -139,8 +141,13 @@ const TimelineList = memo(function TimelineList({
}
const comment = item.comment;
const isHighlighted = highlightCommentId === comment.id;
return (
<div key={comment.id} className="border border-border p-3 overflow-hidden min-w-0 rounded-sm">
<div
key={comment.id}
id={`comment-${comment.id}`}
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${isHighlighted ? "border-primary/50 bg-primary/5" : "border-border"}`}
>
<div className="flex items-center justify-between mb-1">
{comment.authorAgentId ? (
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
@@ -152,9 +159,12 @@ const TimelineList = memo(function TimelineList({
) : (
<Identity name="You" size="sm" />
)}
<span className="text-xs text-muted-foreground">
<a
href={`#comment-${comment.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
{formatDateTime(comment.createdAt)}
</span>
</a>
</div>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
{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<string | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | 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({
<div className="space-y-4">
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length})</h3>
<TimelineList timeline={timeline} agentMap={agentMap} />
<TimelineList timeline={timeline} agentMap={agentMap} highlightCommentId={highlightCommentId} />
{liveRunSlot}