feat: PR description annotations with paragraph-level targeting - #55
Conversation
- Create splitMarkdownBlocks utility for parsing PR body into annotatable blocks with line numbers - Create PrBodyAnnotatable component with hover gutter, numbered dots, annotation bubbles, and popover for creating annotations - Wire into PullRequestView replacing raw HTML rendering - Uses virtual file path (__redpen__/pr-body.md) to reuse existing annotation store and sidecar infrastructure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Create formatPrBodyAnnotations() that collects annotations from the virtual PR body file and formats them as markdown - Pre-populate submit modal textarea with formatted annotations when opening the submit review dialog Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Rust backend reads the file from disk to build the annotation anchor (line content, surrounding lines). The virtual file path didn't exist on disk, causing create_annotation to fail with a file-not-found error. Now we write the PR body markdown to __redpen__/pr-body.md on mount, creating the directory if needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds support for annotating pull request descriptions (PR body) using the existing annotation/bubble system by treating the PR body as a “virtual file” and bundling those annotations into the submit-review summary.
Changes:
- Introduces a markdown block parser (
splitMarkdownBlocks) and preview helper for paragraph-level anchoring. - Adds a
PrBodyAnnotatablecomponent to render PR descriptions with gutter add buttons and inline annotation bubbles. - Pre-fills the Submit Review modal message with formatted PR-body annotations via
formatPrBodyAnnotations.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/review/prBodySummary.ts | Formats PR-body annotations into a markdown summary for the submit-review message. |
| src/lib/markdown/blocks.ts | Splits PR markdown into annotatable blocks and generates short previews. |
| src/components/review-header/ReviewSubmitControl.svelte | Prefills the submit-review message with PR-body annotation summary. |
| src/components/PullRequestView.svelte | Switches PR body rendering to PrBodyAnnotatable. |
| src/components/PrBodyAnnotatable.svelte | New annotatable PR-body renderer with gutter + bubbles + popover. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| getAnnotationsState, | ||
| } from "$lib/stores/annotations.svelte"; | ||
| import { updateChoices } from "$lib/stores/annotations.svelte"; | ||
| import AnnotationPopover from "./AnnotationPopover.svelte"; | ||
| import AnnotationBubble from "./AnnotationBubble.svelte"; |
There was a problem hiding this comment.
getAnnotationsState is imported but never used. With noUnusedLocals: true in tsconfig.json, this should fail the build; remove the import (or use it) and consider consolidating the two imports from the same module into a single import statement.
| getAnnotationsState, | |
| } from "$lib/stores/annotations.svelte"; | |
| import { updateChoices } from "$lib/stores/annotations.svelte"; | |
| import AnnotationPopover from "./AnnotationPopover.svelte"; | |
| import AnnotationBubble from "./AnnotationBubble.svelte"; | |
| updateChoices, | |
| } from "$lib/stores/annotations.svelte"; | |
| import AnnotationPopover from "./AnnotationPopover.svelte"; | |
| import AnnotationBubble from "./AnnotationBubble.svelte"; | |
| import AnnotationBubble from "./AnnotationBubble.svelte"; |
| (async () => { | ||
| try { | ||
| await mkdir(dirPath, { recursive: true }); | ||
| await writeTextFile(virtualFilePath, body); |
There was a problem hiding this comment.
annotationCount is computed but never used. With noUnusedLocals: true, this unused reactive declaration should cause a type-check failure; remove it or wire it into the UI.
| async function openSubmitModal(action: "comment" | "approve" | "requestChanges") { | ||
| showSubmitMenu = false; | ||
| submitModalAction = action; | ||
| submitModalMessage = ""; | ||
| submitModalStatus = "editing"; | ||
| submitModalError = null; | ||
| submitModalResult = null; | ||
|
|
||
| const githubReview = getGitHubReviewState(); | ||
| const session = githubReview.activeSession; | ||
| if (session) { | ||
| submitModalMessage = await formatPrBodyAnnotations( | ||
| session.worktreePath, | ||
| session.body ?? "", | ||
| ); | ||
| } else { | ||
| submitModalMessage = ""; | ||
| } |
There was a problem hiding this comment.
openSubmitModal no longer clears submitModalMessage before awaiting formatPrBodyAnnotations(...), so the modal can briefly show the previous message (and if formatting fails, may leave stale content). Set submitModalMessage = "" before the await (or introduce an explicit loading state) to avoid stale UI.
| let sidecar; | ||
| try { | ||
| sidecar = await getAnnotations(virtualPath); | ||
| } catch { | ||
| return ""; |
There was a problem hiding this comment.
sidecar is declared without a type or initializer (let sidecar;), which becomes an implicit any under strict/noImplicitAny and should fail type-checking. Type it explicitly (e.g., SidecarFile | null) and initialize it (or inline the await getAnnotations(...) into the try block).
| lines.push(`> ${root.body}`); | ||
|
|
||
| const replies = replyMap.get(root.id) ?? []; | ||
| for (const reply of replies) { | ||
| lines.push(`> > \u21B3 ${reply.author}: ${reply.body}`); |
There was a problem hiding this comment.
The markdown quote formatting breaks for multi-line annotation bodies: lines.push("> ${root.body}") (and the reply line) only prefixes the first line with >, so subsequent lines render outside the quote block. Prefix each line of root.body/reply.body with the appropriate quote marker before joining.
| lines.push(`> ${root.body}`); | |
| const replies = replyMap.get(root.id) ?? []; | |
| for (const reply of replies) { | |
| lines.push(`> > \u21B3 ${reply.author}: ${reply.body}`); | |
| const rootBodyLines = root.body.split(/\r?\n/); | |
| for (const bodyLine of rootBodyLines) { | |
| lines.push(`> ${bodyLine}`); | |
| } | |
| const replies = replyMap.get(root.id) ?? []; | |
| for (const reply of replies) { | |
| const replyBodyLines = reply.body.split(/\r?\n/); | |
| replyBodyLines.forEach((replyLine, index) => { | |
| const content = index === 0 | |
| ? `\u21B3 ${reply.author}: ${replyLine}` | |
| : replyLine; | |
| lines.push(`> > ${content}`); | |
| }); |
| const replyMap = new Map<string, Annotation[]>(); | ||
| for (const ann of sidecar.annotations) { | ||
| if (ann.replyTo) { | ||
| const group = replyMap.get(ann.replyTo) ?? []; | ||
| group.push(ann); | ||
| replyMap.set(ann.replyTo, group); | ||
| } | ||
| } |
There was a problem hiding this comment.
Replies are emitted in whatever order sidecar.annotations happens to be in. For stable and chronologically correct summaries, sort each reply group (e.g., by createdAt like sortedAnnotations() does in src/lib/stores/annotations.svelte.ts:69-86) before appending them to lines.
| export function splitMarkdownBlocks(body: string): MarkdownBlock[] { | ||
| const lines = body.split("\n"); | ||
| const blocks: MarkdownBlock[] = []; | ||
| let i = 0; | ||
|
|
||
| while (i < lines.length) { | ||
| const line = lines[i]; | ||
|
|
||
| // Skip blank lines | ||
| if (line.trim() === "") { | ||
| i++; | ||
| continue; | ||
| } | ||
|
|
||
| // Skip separators | ||
| if (SEPARATOR_RE.test(line.trim())) { | ||
| i++; | ||
| continue; | ||
| } | ||
|
|
||
| // Code block (fenced) | ||
| if (CODE_FENCE_RE.test(line.trim())) { | ||
| const startLine = i; | ||
| const collected = [line]; | ||
| i++; | ||
| while (i < lines.length) { | ||
| collected.push(lines[i]); | ||
| if (i > startLine && CODE_FENCE_RE.test(lines[i].trim())) { | ||
| i++; | ||
| break; | ||
| } | ||
| i++; | ||
| } | ||
| blocks.push({ | ||
| lineNumber: startLine + 1, | ||
| content: collected.join("\n"), | ||
| type: "codeBlock", | ||
| }); | ||
| continue; | ||
| } | ||
|
|
||
| // Heading | ||
| if (HEADING_RE.test(line)) { | ||
| blocks.push({ lineNumber: i + 1, content: line, type: "heading" }); | ||
| i++; | ||
| continue; | ||
| } | ||
|
|
||
| // Checklist item | ||
| if (CHECKLIST_RE.test(line.trim())) { | ||
| blocks.push({ lineNumber: i + 1, content: line, type: "checklist" }); | ||
| i++; | ||
| continue; | ||
| } | ||
|
|
||
| // List item (unordered or ordered) | ||
| if (UNORDERED_RE.test(line.trim()) || ORDERED_RE.test(line.trim())) { | ||
| blocks.push({ lineNumber: i + 1, content: line, type: "listItem" }); | ||
| i++; | ||
| continue; | ||
| } | ||
|
|
||
| // Blockquote (consume consecutive `>` lines) | ||
| if (BLOCKQUOTE_RE.test(line)) { | ||
| const startLine = i; | ||
| const collected = [line]; | ||
| i++; | ||
| while (i < lines.length && BLOCKQUOTE_RE.test(lines[i])) { | ||
| collected.push(lines[i]); | ||
| i++; | ||
| } | ||
| blocks.push({ | ||
| lineNumber: startLine + 1, | ||
| content: collected.join("\n"), | ||
| type: "blockquote", | ||
| }); | ||
| continue; | ||
| } | ||
|
|
||
| // Paragraph: consume consecutive non-blank, non-special lines | ||
| { | ||
| const startLine = i; | ||
| const collected = [line]; | ||
| i++; | ||
| while ( | ||
| i < lines.length && | ||
| lines[i].trim() !== "" && | ||
| !SEPARATOR_RE.test(lines[i].trim()) && | ||
| !HEADING_RE.test(lines[i]) && | ||
| !CODE_FENCE_RE.test(lines[i].trim()) && | ||
| !CHECKLIST_RE.test(lines[i].trim()) && | ||
| !UNORDERED_RE.test(lines[i].trim()) && | ||
| !ORDERED_RE.test(lines[i].trim()) && | ||
| !BLOCKQUOTE_RE.test(lines[i]) | ||
| ) { | ||
| collected.push(lines[i]); | ||
| i++; | ||
| } | ||
| blocks.push({ | ||
| lineNumber: startLine + 1, | ||
| content: collected.join("\n"), | ||
| type: "paragraph", | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| return blocks; | ||
| } |
There was a problem hiding this comment.
This file introduces new parsing/formatting behavior but doesn't add tests. There are existing Vitest unit tests for markdown utilities (src/lib/markdown/render.test.ts), so adding a blocks.test.ts covering common structures (headings, paragraphs, ordered/unordered lists, checklists, fenced code blocks, blockquotes, separators, and line numbers) would help prevent regressions.
Summary
Closes #53
Allow reviewers to annotate the PR description using the same gutter dot / collapsed bubble system used for code annotations.
__redpen__/pr-body.md, reusing the existing annotation store, sidecar system, and bubble componentssplitMarkdownBlocksutility: Parses markdown into annotatable blocks (headings, paragraphs, checklist items, code blocks, etc.) with sequential line numbersPrBodyAnnotatablecomponent: Renders PR body with hover-to-annotate gutter, numbered amber dots on annotated blocks, collapsed/expanded AnnotationBubble components+button appears on paragraph hoverformatPrBodyAnnotations)Test plan
+button appears in left gutter with subtle highlight+— verify annotation popover opens, create an annotation🤖 Generated with Claude Code