Skip to content

Commit 6cde395

Browse files
sercantorbacknotpropclaude
authored
feat: add file-scoped comments to /plannotator-review (#303)
* feat(review): add file-scoped comments in code review (#302) * refactor: reuse CommentPopover for file comment form Replace the custom popover implementation in FileHeader (~88 lines of hand-rolled state, click-outside, and escape handling) with the existing CommentPopover component from packages/ui. This gives us expand-to-dialog mode, image attachments, scroll-aware positioning, and keyboard shortcuts for free, while saving ~62 net lines. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Michael Ramos <mdramos8@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e1bd27a commit 6cde395

5 files changed

Lines changed: 132 additions & 21 deletions

File tree

packages/review-editor/App.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,34 @@ function exportReviewFeedback(annotations: CodeAnnotation[], files: DiffFile[]):
9797
for (const [filePath, fileAnnotations] of grouped) {
9898
output += `## ${filePath}\n\n`;
9999

100-
const sorted = [...fileAnnotations].sort((a, b) => a.lineStart - b.lineStart);
100+
const sorted = [...fileAnnotations].sort((a, b) => {
101+
const aScope = a.scope ?? 'line';
102+
const bScope = b.scope ?? 'line';
103+
if (aScope !== bScope) {
104+
return aScope === 'file' ? -1 : 1;
105+
}
106+
return a.lineStart - b.lineStart;
107+
});
101108

102109
for (let i = 0; i < sorted.length; i++) {
103110
const ann = sorted[i];
111+
const scope = ann.scope ?? 'line';
112+
113+
if (scope === 'file') {
114+
output += `### File Comment\n`;
115+
116+
if (ann.text) {
117+
output += `${ann.text}\n`;
118+
}
119+
120+
if (ann.suggestedCode) {
121+
output += `\n**Suggested code:**\n\`\`\`\n${ann.suggestedCode}\n\`\`\`\n`;
122+
}
123+
124+
output += '\n';
125+
continue;
126+
}
127+
104128
const lineRange = ann.lineStart === ann.lineEnd
105129
? `Line ${ann.lineStart}`
106130
: `Lines ${ann.lineStart}-${ann.lineEnd}`;
@@ -279,6 +303,7 @@ const ReviewApp: React.FC = () => {
279303
const newAnnotation: CodeAnnotation = {
280304
id: generateId(),
281305
type,
306+
scope: 'line',
282307
filePath: files[activeFileIndex].path,
283308
lineStart,
284309
lineEnd,
@@ -294,6 +319,27 @@ const ReviewApp: React.FC = () => {
294319
setPendingSelection(null);
295320
}, [pendingSelection, files, activeFileIndex, identity]);
296321

322+
const handleAddFileComment = useCallback((text: string) => {
323+
const activeFile = files[activeFileIndex];
324+
const trimmed = text.trim();
325+
if (!activeFile || !trimmed) return;
326+
327+
const newAnnotation: CodeAnnotation = {
328+
id: generateId(),
329+
type: 'comment',
330+
scope: 'file',
331+
filePath: activeFile.path,
332+
lineStart: 1,
333+
lineEnd: 1,
334+
side: 'new',
335+
text: trimmed,
336+
createdAt: Date.now(),
337+
author: identity,
338+
};
339+
340+
setAnnotations(prev => [...prev, newAnnotation]);
341+
}, [files, activeFileIndex, identity]);
342+
297343
// Edit annotation
298344
const handleEditAnnotation = useCallback((
299345
id: string,
@@ -854,6 +900,7 @@ const ReviewApp: React.FC = () => {
854900
pendingSelection={pendingSelection}
855901
onLineSelection={handleLineSelection}
856902
onAddAnnotation={handleAddAnnotation}
903+
onAddFileComment={handleAddFileComment}
857904
onEditAnnotation={handleEditAnnotation}
858905
onSelectAnnotation={handleSelectAnnotation}
859906
onDeleteAnnotation={handleDeleteAnnotation}

packages/review-editor/components/DiffViewer.tsx

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { FileDiff } from '@pierre/diffs/react';
33
import { getSingularPatch, processFile } from '@pierre/diffs';
44
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata } from '@plannotator/ui/types';
55
import { useTheme } from '@plannotator/ui/components/ThemeProvider';
6+
import { CommentPopover } from '@plannotator/ui/components/CommentPopover';
67
import { detectLanguage } from '../utils/detectLanguage';
78
import { useAnnotationToolbar } from '../hooks/useAnnotationToolbar';
89
import { FileHeader } from './FileHeader';
@@ -20,6 +21,7 @@ interface DiffViewerProps {
2021
pendingSelection: SelectedLineRange | null;
2122
onLineSelection: (range: SelectedLineRange | null) => void;
2223
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string) => void;
24+
onAddFileComment: (text: string) => void;
2325
onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string) => void;
2426
onSelectAnnotation: (id: string | null) => void;
2527
onDeleteAnnotation: (id: string) => void;
@@ -42,6 +44,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
4244
pendingSelection,
4345
onLineSelection,
4446
onAddAnnotation,
47+
onAddFileComment,
4548
onEditAnnotation,
4649
onSelectAnnotation,
4750
onDeleteAnnotation,
@@ -55,6 +58,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
5558
}) => {
5659
const { theme, colorTheme, resolvedMode } = useTheme();
5760
const containerRef = useRef<HTMLDivElement>(null);
61+
const [fileCommentAnchor, setFileCommentAnchor] = useState<HTMLElement | null>(null);
5862

5963
const toolbar = useAnnotationToolbar({ patch, filePath, onLineSelection, onAddAnnotation, onEditAnnotation });
6064

@@ -118,18 +122,20 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
118122

119123
// Map annotations to @pierre/diffs format
120124
const lineAnnotations = useMemo(() => {
121-
return annotations.map(ann => ({
122-
side: ann.side === 'new' ? 'additions' as const : 'deletions' as const,
123-
lineNumber: ann.lineEnd,
124-
metadata: {
125-
annotationId: ann.id,
126-
type: ann.type,
127-
text: ann.text,
128-
suggestedCode: ann.suggestedCode,
129-
originalCode: ann.originalCode,
130-
author: ann.author,
131-
} as DiffAnnotationMetadata,
132-
}));
125+
return annotations
126+
.filter(ann => (ann.scope ?? 'line') === 'line')
127+
.map(ann => ({
128+
side: ann.side === 'new' ? 'additions' as const : 'deletions' as const,
129+
lineNumber: ann.lineEnd,
130+
metadata: {
131+
annotationId: ann.id,
132+
type: ann.type,
133+
text: ann.text,
134+
suggestedCode: ann.suggestedCode,
135+
originalCode: ann.originalCode,
136+
author: ann.author,
137+
} as DiffAnnotationMetadata,
138+
}));
133139
}, [annotations]);
134140

135141
// Handle edit: find annotation and start editing in toolbar
@@ -218,6 +224,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
218224
onStage={onStage}
219225
canStage={canStage}
220226
stageError={stageError}
227+
onFileComment={setFileCommentAnchor}
221228
/>
222229

223230
<div className="p-4">
@@ -271,6 +278,19 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
271278
onClose={() => toolbar.setShowCodeModal(false)}
272279
/>
273280
)}
281+
282+
{fileCommentAnchor && (
283+
<CommentPopover
284+
anchorEl={fileCommentAnchor}
285+
contextText={filePath.split('/').pop() || filePath}
286+
isGlobal={false}
287+
onSubmit={(text) => {
288+
onAddFileComment(text);
289+
setFileCommentAnchor(null);
290+
}}
291+
onClose={() => setFileCommentAnchor(null)}
292+
/>
293+
)}
274294
</div>
275295
);
276296
};

packages/review-editor/components/FileHeader.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState } from 'react';
1+
import React, { useRef, useState } from 'react';
22

33
interface FileHeaderProps {
44
filePath: string;
@@ -10,6 +10,7 @@ interface FileHeaderProps {
1010
onStage?: () => void;
1111
canStage?: boolean;
1212
stageError?: string | null;
13+
onFileComment?: (anchorEl: HTMLElement) => void;
1314
}
1415

1516
/** Sticky file header with file path, Viewed toggle, Git Add, and Copy Diff button */
@@ -23,8 +24,10 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
2324
onStage,
2425
canStage = false,
2526
stageError,
27+
onFileComment,
2628
}) => {
2729
const [copied, setCopied] = useState(false);
30+
const fileCommentRef = useRef<HTMLButtonElement>(null);
2831

2932
return (
3033
<div className="sticky top-0 z-10 px-4 py-2 bg-card/95 backdrop-blur border-b border-border flex items-center justify-between">
@@ -85,6 +88,19 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
8588
{stageError && (
8689
<span className="text-xs text-destructive">{stageError}</span>
8790
)}
91+
{onFileComment && (
92+
<button
93+
ref={fileCommentRef}
94+
onClick={() => fileCommentRef.current && onFileComment(fileCommentRef.current)}
95+
className="text-xs px-2 py-1 rounded transition-colors flex items-center gap-1 text-muted-foreground hover:text-foreground hover:bg-muted"
96+
title="Add file-scoped comment"
97+
>
98+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
99+
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-4l-4 4v-4z" />
100+
</svg>
101+
File Comment
102+
</button>
103+
)}
88104
<button
89105
onClick={async () => {
90106
try {

packages/review-editor/components/ReviewPanel.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,25 @@ function formatTimestamp(ts: number): string {
6868
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
6969
}
7070

71+
const FILE_SCOPE_FIRST = { file: 0, line: 1 } as const;
72+
73+
function getAnnotationScope(annotation: CodeAnnotation): 'line' | 'file' {
74+
return annotation.scope ?? 'line';
75+
}
76+
77+
function compareCodeAnnotations(a: CodeAnnotation, b: CodeAnnotation): number {
78+
const aScope = getAnnotationScope(a);
79+
const bScope = getAnnotationScope(b);
80+
81+
if (aScope !== bScope) {
82+
return FILE_SCOPE_FIRST[aScope] - FILE_SCOPE_FIRST[bScope];
83+
}
84+
85+
return aScope === 'file'
86+
? b.createdAt - a.createdAt
87+
: a.lineStart - b.lineStart;
88+
}
89+
7190
export const ReviewPanel: React.FC<ReviewPanelProps> = ({
7291
isOpen,
7392
onToggle,
@@ -102,9 +121,9 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
102121
existing.push(ann);
103122
grouped.set(ann.filePath, existing);
104123
}
105-
// Sort each group by line number
124+
// Sort file comments first, then line comments
106125
for (const [, anns] of grouped) {
107-
anns.sort((a, b) => a.lineStart - b.lineStart);
126+
anns.sort(compareCodeAnnotations);
108127
}
109128
return grouped;
110129
}, [annotations]);
@@ -151,6 +170,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
151170
<div className="space-y-1">
152171
{fileAnnotations.map((annotation) => {
153172
const isSelected = selectedAnnotationId === annotation.id;
173+
const isFileScope = getAnnotationScope(annotation) === 'file';
154174
return (
155175
<div
156176
key={annotation.id}
@@ -164,11 +184,17 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
164184
{/* Header: Line + Timestamp */}
165185
<div className="flex items-center justify-between mb-1.5">
166186
<div className="flex items-center gap-2">
167-
<span className="text-[10px] font-mono text-muted-foreground">
168-
{annotation.lineStart === annotation.lineEnd
169-
? `L${annotation.lineStart}`
170-
: `L${annotation.lineStart}-${annotation.lineEnd}`}
171-
</span>
187+
{isFileScope ? (
188+
<span className="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
189+
file
190+
</span>
191+
) : (
192+
<span className="text-[10px] font-mono text-muted-foreground">
193+
{annotation.lineStart === annotation.lineEnd
194+
? `L${annotation.lineStart}`
195+
: `L${annotation.lineStart}-${annotation.lineEnd}`}
196+
</span>
197+
)}
172198
{annotation.author && (
173199
<span className={`text-[10px] truncate max-w-[100px] ${isCurrentUser(annotation.author) ? 'text-muted-foreground/50' : 'text-muted-foreground/70'}`}>
174200
{annotation.author}{isCurrentUser(annotation.author) && ' (me)'}

packages/ui/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,12 @@ export interface DiffResult {
6060

6161
// Code Review Types
6262
export type CodeAnnotationType = 'comment' | 'suggestion' | 'concern';
63+
export type CodeAnnotationScope = 'line' | 'file';
6364

6465
export interface CodeAnnotation {
6566
id: string;
6667
type: CodeAnnotationType;
68+
scope?: CodeAnnotationScope; // Defaults to 'line' for backward compatibility
6769
filePath: string;
6870
lineStart: number;
6971
lineEnd: number;

0 commit comments

Comments
 (0)