From 174b4daadf7dce295af344d431f7b0c6b37e4e66 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 26 Jun 2026 09:14:49 -0700 Subject: [PATCH 01/10] feat(review): file comments in diff + unified click-to-highlight comment UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render file-scoped comments inline in the code review UI and overhaul the comment-card UX for consistency. File comments - Single-file view: a full-text banner below the file path. - All-files view: rendered in the file header (expanded files only) — Pierre's virtualized custom header suppresses the body-prefix slot, and its measured item heights make a taller header safe. Guided-review external annotations can now drop the line-1 anchor hack. Click-to-highlight - Clicking a comment (inline card or sidebar) replays its stored line range as the controlled selection highlight; clicking it again clears it. - Scroll/navigate is sidebar- and findings-list-only (token-based signal) so clicking a comment in the diff never moves the viewport. Unified comment cards - Shared CommentMeta identity row (badges + author + relative time) across the inline, sidebar, and file-banner cards (were three hand-rolled headers). - Shared FileNameChip (basename, full path on hover) replaces the literal "file" badge everywhere. - Force the app sans font on cards (inline was inheriting the diff's monospace), consistent selected ring, spacing, and absolute hover actions so timestamps align across surfaces. Shared helpers: annotationScope (PR-scope / file-scope / line-range projection) and fileName (basename). Plumbs createdAt through DiffAnnotationMetadata so the inline cards can show timestamps. --- bun.lock | 6 +- packages/review-editor/App.tsx | 42 +++-- packages/review-editor/components/AITab.tsx | 7 +- .../components/AllFilesCodeView.tsx | 153 +++++++++++---- .../review-editor/components/CommentMeta.tsx | 75 ++++++++ .../review-editor/components/DiffViewer.tsx | 49 ++++- .../components/FileCommentBanner.tsx | 176 ++++++++++++++++++ .../review-editor/components/FileNameChip.tsx | 16 ++ .../components/InlineAnnotation.tsx | 82 ++++---- .../components/ReviewSidebar.tsx | 48 ++--- .../review-editor/dock/ReviewStateContext.tsx | 8 +- .../dock/panels/ReviewAgentJobDetailPanel.tsx | 6 +- .../dock/panels/ReviewAllFilesDiffPanel.tsx | 1 + .../dock/panels/ReviewDiffPanel.tsx | 5 +- packages/review-editor/index.css | 44 ++++- packages/review-editor/types.ts | 11 ++ .../review-editor/utils/annotationScope.ts | 39 ++++ packages/review-editor/utils/fileName.ts | 4 + packages/ui/types.ts | 5 + 19 files changed, 628 insertions(+), 149 deletions(-) create mode 100644 packages/review-editor/components/CommentMeta.tsx create mode 100644 packages/review-editor/components/FileCommentBanner.tsx create mode 100644 packages/review-editor/components/FileNameChip.tsx create mode 100644 packages/review-editor/utils/annotationScope.ts create mode 100644 packages/review-editor/utils/fileName.ts diff --git a/bun.lock b/bun.lock index f49daa850..962817544 100644 --- a/bun.lock +++ b/bun.lock @@ -64,7 +64,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.21.1", + "version": "0.21.2", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -86,7 +86,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.21.1", + "version": "0.21.2", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "1.2.8", @@ -215,7 +215,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.21.1", + "version": "0.21.2", "dependencies": { "@pierre/diffs": "1.2.8", "@plannotator/ai": "workspace:*", diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 0a8e7ab7b..01f6984e8 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -85,7 +85,7 @@ import { REVIEW_ALL_FILES_PANEL_ID, REVIEW_CODE_NAV_PANEL_ID, } from './dock/reviewPanelTypes'; -import type { DiffFile } from './types'; +import type { DiffFile, AnnotationScrollTarget } from './types'; import type { DiffOption, WorktreeInfo, GitContext } from '@plannotator/shared/types'; import type { PRMetadata } from '@plannotator/shared/pr-types'; import type { PRDiffScope, PRDiffScopeOption, PRStackInfo, PRStackTree } from '@plannotator/shared/pr-stack'; @@ -121,6 +121,11 @@ const ReviewApp: React.FC = () => { const [activeFileIndex, setActiveFileIndex] = useState(0); const [annotations, setAnnotations] = useState([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + // Sidebar-initiated "scroll to this comment" signal. The token bumps on every + // sidebar click so re-selecting the same comment re-navigates. Selecting a + // comment in the diff sets selectedAnnotationId but NOT this — so it never + // moves the viewport. + const [scrollTargetAnnotation, setScrollTargetAnnotation] = useState(null); const [isAllFilesActive, setIsAllFilesActive] = useState(false); // Mirror ref: handlers captured by Pierre slot portals (which only republish // on item version bumps) and early-declared callbacks read the CURRENT value @@ -1511,29 +1516,29 @@ const ReviewApp: React.FC = () => { // handler is baked into Pierre slot portals, which only republish on item // version bumps — a stale captured value would yank the user out of the // all-files tab into the single-file panel when they click an annotation. + // Inline-card selection: toggle the highlight + ring only. No scroll, no file + // switch — the clicked card is already on screen. Clicking the selected card + // again (or a null id) clears it. const handleSelectAnnotation = useCallback((id: string | null) => { + setSelectedAnnotationId(prev => (!id || prev === id ? null : id)); + }, []); + + // Sidebar navigation: select AND scroll-to the comment (DiffsHub "set + + // scroll"). The token bump re-fires the panels' scroll effect even when the + // same comment is clicked twice; in single-file mode it switches to the + // owning file first so the scroll target exists. + const handleNavigateToAnnotation = useCallback((id: string | null) => { if (!id) { setSelectedAnnotationId(null); return; } - - // Find the annotation const annotation = allAnnotationsRef.current.find(a => a.id === id); - if (!annotation) { - setSelectedAnnotationId(id); - return; - } - - // In all-files mode, just set the selection — the panel's scroll-to-annotation - // effect handles expanding and scrolling. In single-file mode, switch to the file. - if (!isAllFilesActiveRef.current) { + if (annotation && !isAllFilesActiveRef.current) { const fileIndex = files.findIndex(f => f.path === annotation.filePath); - if (fileIndex !== -1) { - handleFileSwitch(fileIndex); - } + if (fileIndex !== -1) handleFileSwitch(fileIndex); } - setSelectedAnnotationId(id); + setScrollTargetAnnotation(prev => ({ id, token: (prev?.token ?? 0) + 1 })); }, [files, handleFileSwitch]); // Diff context bundled into local-mode feedback headers so the receiving @@ -1596,6 +1601,7 @@ const ReviewApp: React.FC = () => { allAnnotations, externalAnnotations, selectedAnnotationId, + scrollTargetAnnotation, pendingSelection, onLineSelection: handleLineSelection, onAddAnnotation: handleAddAnnotation, @@ -1604,6 +1610,7 @@ const ReviewApp: React.FC = () => { onAddFileCommentForFile: handleAddFileCommentForFile, onEditAnnotation: handleEditAnnotation, onSelectAnnotation: handleSelectAnnotation, + onNavigateToAnnotation: handleNavigateToAnnotation, onDeleteAnnotation: handleDeleteAnnotation, viewedFiles, onToggleViewed: handleToggleViewed, @@ -1654,9 +1661,9 @@ const ReviewApp: React.FC = () => { diffLineDiffType, diffShowLineNumbers, diffShowBackground, diffExpandUnchanged, diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope, agentCwd, allAnnotations, externalAnnotations, - selectedAnnotationId, pendingSelection, handleLineSelection, + selectedAnnotationId, scrollTargetAnnotation, pendingSelection, handleLineSelection, handleAddAnnotation, handleAddFileComment, handleAddFileCommentForFile, handleEditAnnotation, - handleSelectAnnotation, handleDeleteAnnotation, viewedFiles, + handleSelectAnnotation, handleNavigateToAnnotation, handleDeleteAnnotation, viewedFiles, handleToggleViewed, stagedFiles, stagingFile, stageFile, canStageFiles, stageError, isSearchPending, debouncedSearchQuery, activeFileSearchMatches, activeSearchMatchId, activeSearchMatch, searchMatches, @@ -2575,6 +2582,7 @@ const ReviewApp: React.FC = () => { files={files} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} + onNavigateToAnnotation={handleNavigateToAnnotation} onDeleteAnnotation={handleDeleteAnnotation} feedbackMarkdown={feedbackMarkdown} width={panelResize.width} diff --git a/packages/review-editor/components/AITab.tsx b/packages/review-editor/components/AITab.tsx index e3e865ad9..91eeae08b 100644 --- a/packages/review-editor/components/AITab.tsx +++ b/packages/review-editor/components/AITab.tsx @@ -3,6 +3,7 @@ import type { AIChatEntry, PendingPermission } from '../hooks/useAIChat'; import { renderChatMarkdown } from '../utils/renderChatMarkdown'; import { formatLineRange } from '../utils/formatLineRange'; import { formatRelativeTime } from '../utils/formatRelativeTime'; +import { FileNameChip } from './FileNameChip'; import { SparklesIcon } from '@plannotator/ui/components/SparklesIcon'; import { CountBadge } from './CountBadge'; import { CopyButton } from './CopyButton'; @@ -352,10 +353,8 @@ const QAPair = memo<{ {formatLineRange(question.lineStart, question.lineEnd)} )} - {scope === 'file' && ( - - file - + {scope === 'file' && question.filePath && ( + )} diff --git a/packages/review-editor/components/AllFilesCodeView.tsx b/packages/review-editor/components/AllFilesCodeView.tsx index 81e0f114d..2a304d64c 100644 --- a/packages/review-editor/components/AllFilesCodeView.tsx +++ b/packages/review-editor/components/AllFilesCodeView.tsx @@ -23,13 +23,15 @@ import type { import { CommentPopover } from '@plannotator/ui/components/CommentPopover'; import { usePierreTheme } from '../hooks/usePierreTheme'; import { useIsWorkerPoolReadyOrDisabled, useWorkerPoolThemeSync } from '../workerPool'; -import type { DiffFile } from '../types'; +import type { DiffFile, AnnotationScrollTarget } from '../types'; import { buildFileTree, getVisualFileOrder } from '../utils/buildFileTree'; import { buildCodeNavRequest } from '../utils/buildCodeNavRequest'; import { getDiffSelection, getLineNumberFromNode, getSideFromNode } from '../utils/diffSelection'; import { isContentConsistentWithPatch } from '../utils/patchConsistency'; import { ToolbarHost, type ToolbarHostHandle } from './ToolbarHost'; import { FileHeader } from './FileHeader'; +import { FileCommentBanner } from './FileCommentBanner'; +import { annotationMatchesPrScope, isFileScopedAnnotation, lineRangeForAnnotation } from '../utils/annotationScope'; import { InlineAnnotation } from './InlineAnnotation'; import { detectLanguage } from '../utils/detectLanguage'; import type { AIChatEntry } from '../hooks/useAIChat'; @@ -153,6 +155,7 @@ interface AllFilesCodeViewProps { // line annotations render through CodeView item state. annotations: CodeAnnotation[]; selectedAnnotationId: string | null; + scrollTargetAnnotation: AnnotationScrollTarget | null; pendingSelection: SelectedLineRange | null; reviewBase?: string; // Annotation / toolbar wiring (P2). Mirrors AllFilesDiffView's surface so the @@ -244,11 +247,14 @@ function hashString(value: string): string { return hash.toString(36); } -// Project a file's line annotations into Pierre's DiffLineAnnotation shape. This -// is the EXACT projection AllFilesDiffView builds (side, lineNumber = lineEnd, -// metadata = DiffAnnotationMetadata) so the two surfaces render identically. -// Filters to line-scoped annotations that belong to this file in the active -// PR/diff-scope (file-scoped comments live in the header, not the gutter). +// The first rendered line of a file's diff, used to anchor file-scoped comments. +// Pierre suppresses the header-prefix slot whenever a custom header is present +// (renderDiffChildren makes them mutually exclusive), so file comments can't +// live "between header and body" — instead they ride the line-annotation slot +// Project a file's LINE annotations into Pierre's DiffLineAnnotation shape (side, +// lineNumber = lineEnd, metadata = DiffAnnotationMetadata). File-scoped comments +// are deliberately excluded — they render in the file header (renderCustomHeader), +// not the gutter (see fileCommentsByPath). function projectFileAnnotations( annotations: CodeAnnotation[], filePath: string, @@ -260,8 +266,7 @@ function projectFileAnnotations( (a) => a.filePath === filePath && (a.scope ?? 'line') === 'line' && - (!a.prUrl || !prUrl || a.prUrl === prUrl) && - (!a.diffScope || !prDiffScope || a.diffScope === prDiffScope), + annotationMatchesPrScope(a, prUrl, prDiffScope), ) .map((ann) => ({ side: ann.side === 'new' ? ('additions' as const) : ('deletions' as const), @@ -277,6 +282,9 @@ function projectFileAnnotations( reasoning: ann.reasoning, conventionalLabel: ann.conventionalLabel, decorations: ann.decorations, + createdAt: ann.createdAt, + reviewProfileLabel: ann.reviewProfileLabel, + source: ann.source, } as DiffAnnotationMetadata, })); } @@ -384,6 +392,7 @@ export const AllFilesCodeView: React.FC = ({ fontSize, annotations, selectedAnnotationId, + scrollTargetAnnotation, pendingSelection, reviewBase, onLineSelection, @@ -659,6 +668,21 @@ export const AllFilesCodeView: React.FC = ({ toolbarHostRef.current?.startEdit(ann); }); + // Per-file file-scoped comments, namespaced to the active PR/diff-scope. These + // render in the file HEADER (renderCustomHeader, below the path) when the file + // is expanded — not in the gutter — so they read as a file-level note rather + // than a stray line comment. + const fileCommentsByPath = useMemo(() => { + const map = new Map(); + for (const a of annotations) { + if (!isFileScopedAnnotation(a) || !annotationMatchesPrScope(a, prUrl, prDiffScope)) continue; + const arr = map.get(a.filePath); + if (arr) arr.push(a); + else map.set(a.filePath, [a]); + } + return map; + }, [annotations, prUrl, prDiffScope]); + // Render a single annotation from item state. `renderAnnotation` receives both // the LineAnnotation and DiffLineAnnotation union — guard `'side' in // annotation && item.type === 'diff'` (the Diffshub pattern) so file-item @@ -678,6 +702,7 @@ export const AllFilesCodeView: React.FC = ({ = ({ const signatures = (list: CodeAnnotation[]) => { const map = new Map(); for (const a of list) { - if ((a.scope ?? 'line') !== 'line') continue; - if (a.prUrl && prUrl && a.prUrl !== prUrl) continue; - if (a.diffScope && prDiffScope && a.diffScope !== prDiffScope) continue; - const sig = JSON.stringify([ - a.id, a.lineEnd, a.side, a.type, - a.text ?? '', a.suggestedCode ?? '', a.originalCode ?? '', - a.conventionalLabel ?? '', (a.decorations ?? []).join(','), - a.severity ?? '', a.reasoning ?? '', a.author ?? '', - ]); + const scope = a.scope ?? 'line'; + if (scope !== 'line' && scope !== 'file') continue; + if (!annotationMatchesPrScope(a, prUrl, prDiffScope)) continue; + // File comments carry different render-affecting fields than line notes + // (no line/side/suggestion; they DO surface source + profile badges). + const sig = scope === 'file' + ? JSON.stringify([ + 'F', a.id, a.text ?? '', a.source ?? '', a.author ?? '', + a.reviewProfileLabel ?? '', a.conventionalLabel ?? '', + ]) + : JSON.stringify([ + a.id, a.lineEnd, a.side, a.type, + a.text ?? '', a.suggestedCode ?? '', a.originalCode ?? '', + a.conventionalLabel ?? '', (a.decorations ?? []).join(','), + a.severity ?? '', a.reasoning ?? '', a.author ?? '', + ]); map.set(a.filePath, `${map.get(a.filePath) ?? ''}${sig}\n`); } return map; @@ -1543,26 +1575,52 @@ export const AllFilesCodeView: React.FC = ({ viewer.scrollTo({ type: 'item', id: itemId, align: 'start' }); }, []); - // --- Selected-annotation navigation (P4) ----------------------------------- - - // Selecting an annotation in the sidebar must expand its owning file (if - // collapsed) and scroll to it. We expand via item state (collapsed=false + - // version bump + updateItem — the Diffshub pattern), then scrollTo the - // annotation's line range so it lands in view. rAF defers the scroll one frame - // so the expand's layout has settled before CodeView resolves the line top. - // `annotations` is read through the ref, NOT the dep list: this must fire only - // when the SELECTION changes. With `annotations` as a dep, any annotation - // change while one is selected (add/edit/delete elsewhere, an external SSE - // annotation arriving) re-runs the effect and yanks the viewport back to the - // selected annotation with zero user action. + // --- Selected-annotation highlight + navigation ---------------------------- + + // SELECTION (inline card OR sidebar) paints the line highlight + repaints the + // card ring — but NEVER scrolls. Clicking a comment in the diff must not move + // the viewport. `annotations` is read through the ref so this fires only on + // selection change, not on any add/edit/delete while one is selected. + const prevSelectedFileRef = useRef(null); useEffect(() => { - if (!selectedAnnotationId) return; - const ann = annotationsRef.current.find((a) => a.id === selectedAnnotationId); + const ann = selectedAnnotationId + ? annotationsRef.current.find((a) => a.id === selectedAnnotationId) + : null; + const newFile = ann?.filePath ?? null; + + // Repaint the inline card's selected ring on the previously- AND + // newly-selected file: renderAnnotation only re-runs on updateItem, so a bare + // selection-state change wouldn't otherwise reach the portal'd cards. + const filesToRefresh = new Set(); + if (prevSelectedFileRef.current) filesToRefresh.add(prevSelectedFileRef.current); + if (newFile) filesToRefresh.add(newFile); + prevSelectedFileRef.current = newFile; + for (const path of filesToRefresh) { + for (const itemId of filePathToItemIds.get(path) ?? []) refreshItem(itemId); + } + + // Line/range comments replay their exact range as the controlled highlight + // (the same state a drag paints). File-scoped comments / deselection clear it. + const itemId = ann ? filePathToItemId.get(ann.filePath) : undefined; + if (!ann || isFileScopedAnnotation(ann) || itemId == null) { + setSelectedLines((prev) => (prev ? null : prev)); + return; + } + setSelectedLines({ id: itemId, range: lineRangeForAnnotation(ann) }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedAnnotationId, filePathToItemId, filePathToItemIds, refreshItem]); + + // NAVIGATION (sidebar only) — expand the owning file if collapsed and scroll + // to the comment. Keyed on the navigation token so it fires per sidebar click + // (re-clicking the same comment re-centers it) and NEVER on a bare in-diff + // selection. rAF defers the scroll a frame so the expand's layout has settled. + useEffect(() => { + if (!scrollTargetAnnotation) return; + const ann = annotationsRef.current.find((a) => a.id === scrollTargetAnnotation.id); if (!ann) return; const itemId = filePathToItemId.get(ann.filePath); - if (itemId == null) return; const handle = viewerRef.current; - if (handle == null) return; + if (itemId == null || handle == null) return; const item = handle.getItem(itemId); if (item != null && item.collapsed === true) { @@ -1571,17 +1629,17 @@ export const AllFilesCodeView: React.FC = ({ handle.updateItem(item); } - const start = Math.min(ann.lineStart, ann.lineEnd); - const end = Math.max(ann.lineStart, ann.lineEnd); - const side = ann.side === 'new' ? ('additions' as const) : ('deletions' as const); + const isFile = isFileScopedAnnotation(ann); + const range = lineRangeForAnnotation(ann); const raf = requestAnimationFrame(() => { const viewer = viewerRef.current; if (viewer == null) return; - viewer.scrollTo({ type: 'range', id: itemId, range: { start, end, side } }); + if (isFile) viewer.scrollTo({ type: 'item', id: itemId, align: 'start' }); + else viewer.scrollTo({ type: 'range', id: itemId, range }); }); return () => cancelAnimationFrame(raf); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedAnnotationId, filePathToItemId]); + }, [scrollTargetAnnotation, filePathToItemId]); useEffect(() => { if (!isActive) return; @@ -1699,9 +1757,11 @@ export const AllFilesCodeView: React.FC = ({ if (file == null) return null; const collapsed = item.collapsed === true; + const fileComments = fileCommentsByPath.get(filePath) ?? []; return ( - + = ({ } onCollapseToggle={() => toggleItemCollapsed(item.id)} - /> + /> + {/* File-scoped comments live in the header (below the path), shown only + when the file is expanded. They ride the sticky header — fine for a + short guide note; long ones scroll within the banner. */} + {!collapsed && fileComments.length > 0 && ( + + )} + ); }); diff --git a/packages/review-editor/components/CommentMeta.tsx b/packages/review-editor/components/CommentMeta.tsx new file mode 100644 index 000000000..58ca56ad6 --- /dev/null +++ b/packages/review-editor/components/CommentMeta.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import type { ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types'; +import { isCurrentUser } from '@plannotator/ui/utils/identity'; +import { ConventionalLabelBadge } from './ConventionalLabelPicker'; +import { formatRelativeTime } from '../utils/formatRelativeTime'; + +interface CommentMetaProps { + /** Surface-specific leading element(s): severity dot, scope/file/line badge, + * collapse toggle, etc. Rendered first in the left cluster. */ + leading?: React.ReactNode; + conventionalLabel?: ConventionalLabel | null; + decorations?: ConventionalDecoration[]; + reviewProfileLabel?: string; + source?: string; + author?: string; + createdAt?: number; + /** Surface-specific right-aligned controls (e.g. edit/delete actions), shown + * after the timestamp. */ + trailing?: React.ReactNode; +} + +/** + * The single identity row shared by every comment surface — the inline diff + * card, the sidebar list, and the file-comment banner. Left cluster: leading + * badge(s) → conventional label → review-profile/source badge → author. Right: + * relative time, then any surface-specific actions. Centralizing it keeps author + * + timestamp + badge styling identical everywhere (they used to be hand-rolled + * three different ways). + */ +export const CommentMeta: React.FC = ({ + leading, + conventionalLabel, + decorations, + reviewProfileLabel, + source, + author, + createdAt, + trailing, +}) => ( +
+
+ {leading} + {conventionalLabel && ( + + )} + {reviewProfileLabel ? ( + + {reviewProfileLabel} + + ) : source ? ( + + {source} + + ) : null} + {author && ( + + {author} + {isCurrentUser(author) && ' (me)'} + + )} +
+ {(createdAt != null || trailing) && ( +
+ {createdAt != null && ( + {formatRelativeTime(createdAt)} + )} + {trailing} +
+ )} +
+); diff --git a/packages/review-editor/components/DiffViewer.tsx b/packages/review-editor/components/DiffViewer.tsx index affd4d457..3bf66f1a1 100644 --- a/packages/review-editor/components/DiffViewer.tsx +++ b/packages/review-editor/components/DiffViewer.tsx @@ -13,6 +13,9 @@ import { ToolbarHost, type ToolbarHostHandle } from './ToolbarHost'; import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport'; import { FileHeader } from './FileHeader'; +import { FileCommentBanner } from './FileCommentBanner'; +import { isFileScopedAnnotation, lineRangeForAnnotation } from '../utils/annotationScope'; +import type { AnnotationScrollTarget } from '../types'; import { getLineNumberFromNode, getSideFromNode, getDiffSelection } from '../utils/diffSelection'; import { isContentConsistentWithPatch } from '../utils/patchConsistency'; import { InlineAnnotation } from './InlineAnnotation'; @@ -152,6 +155,7 @@ interface DiffViewerProps { fontSize?: string; annotations: CodeAnnotation[]; selectedAnnotationId: string | null; + scrollTargetAnnotation: AnnotationScrollTarget | null; pendingSelection: SelectedLineRange | null; onLineSelection: (range: SelectedLineRange | null) => void; onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel, decorations?: ConventionalDecoration[], tokenMeta?: TokenAnnotationMeta) => void; @@ -203,6 +207,7 @@ export const DiffViewer: React.FC = ({ fontSize, annotations, selectedAnnotationId, + scrollTargetAnnotation, pendingSelection, onLineSelection, onAddAnnotation, @@ -406,13 +411,16 @@ export const DiffViewer: React.FC = ({ return () => viewport.removeEventListener('scroll', onScroll); }, [viewport, filePath]); - // Scroll to selected annotation when it changes + // Scroll to a comment ONLY on sidebar navigation (scrollTargetAnnotation), + // never on a bare in-diff selection — clicking a comment must not move the + // viewport. Keyed on the token so re-clicking the same sidebar row re-centers. useEffect(() => { - if (!selectedAnnotationId || !containerRef.current) return; + if (!scrollTargetAnnotation || !containerRef.current) return; + const targetId = scrollTargetAnnotation.id; const timeoutId = setTimeout(() => { const annotationEl = containerRef.current?.querySelector( - `[data-annotation-id="${selectedAnnotationId}"]` + `[data-annotation-id="${targetId}"]` ); if (annotationEl) { annotationEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); @@ -420,7 +428,7 @@ export const DiffViewer: React.FC = ({ }, 100); return () => clearTimeout(timeoutId); - }, [selectedAnnotationId, viewport]); + }, [scrollTargetAnnotation, viewport]); // Apply search highlights to diff lines (including inside shadow DOM). // The query is already debounced upstream (useReviewSearch), so this runs synchronously. @@ -515,6 +523,9 @@ export const DiffViewer: React.FC = ({ reasoning: ann.reasoning, conventionalLabel: ann.conventionalLabel, decorations: ann.decorations, + createdAt: ann.createdAt, + reviewProfileLabel: ann.reviewProfileLabel, + source: ann.source, } as DiffAnnotationMetadata, })); }, [annotations]); @@ -570,12 +581,13 @@ export const DiffViewer: React.FC = ({ ); - }, [filePath, onSelectAnnotation, handleEdit, onDeleteAnnotation, onClickAIMarker]); + }, [filePath, selectedAnnotationId, onSelectAnnotation, handleEdit, onDeleteAnnotation, onClickAIMarker]); const handleGutterUtilityClick = useCallback((range: SelectedLineRange) => { toolbarHostRef.current?.handleLineSelectionEnd(range); @@ -638,6 +650,24 @@ export const DiffViewer: React.FC = ({ } as React.CSSProperties; }, [diffOverflow, isSplitLayout, splitRatio]); + // File-scoped comments render below the path, above the hunks (full text, no + // truncation) — line-scoped annotations stay inline in the gutter. + const fileComments = useMemo( + () => annotations.filter(isFileScopedAnnotation), + [annotations], + ); + + // Replay a selected line/range comment's anchor as the controlled highlight so + // clicking it (inline card or sidebar) lights up its lines. A live compose + // selection (pendingSelection) wins while the toolbar is open; file-scoped + // comments have no meaningful line so they don't paint a highlight. + const selectedAnnotationRange = useMemo(() => { + if (!selectedAnnotationId) return null; + const ann = annotations.find((a) => a.id === selectedAnnotationId); + if (!ann || isFileScopedAnnotation(ann)) return null; + return lineRangeForAnnotation(ann); + }, [selectedAnnotationId, annotations]); + return (
= ({ overflowX="scroll" onViewportReady={onViewportReady} > +
{isSplitLayout && diffOverflow !== 'wrap' && ( @@ -684,7 +721,7 @@ export const DiffViewer: React.FC = ({ disableBackground={disableBackground} expandUnchanged={expandUnchanged} mergedAnnotations={mergedAnnotations} - pendingSelection={pendingSelection} + pendingSelection={pendingSelection ?? selectedAnnotationRange} onLineSelectionEnd={handlePierreLineSelectionEnd} onGutterUtilityClick={handleGutterUtilityClick} renderAnnotation={renderAnnotation} diff --git a/packages/review-editor/components/FileCommentBanner.tsx b/packages/review-editor/components/FileCommentBanner.tsx new file mode 100644 index 000000000..3984b1f02 --- /dev/null +++ b/packages/review-editor/components/FileCommentBanner.tsx @@ -0,0 +1,176 @@ +import React, { useMemo, useState } from 'react'; +import type { CodeAnnotation } from '@plannotator/ui/types'; +import { sanitizeBlockHtml } from '@plannotator/ui/utils/sanitizeHtml'; +import { CommentMeta } from './CommentMeta'; +import { FileNameChip } from './FileNameChip'; + +interface FileCommentBannerProps { + /** File-scoped comments for ONE file (already filtered to scope === 'file'). */ + comments: CodeAnnotation[]; + selectedAnnotationId: string | null; + onSelect: (id: string | null) => void; + onEdit: (id: string, text: string) => void; + onDelete: (id: string) => void; +} + +/** First non-empty line of the comment, used as the collapsed one-line preview. */ +function firstLine(text: string): string { + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (trimmed) return trimmed.replace(/^#+\s*/, '').replace(/[*_`>]/g, ''); + } + return text.trim(); +} + +/** + * A single file-scoped comment card. Exported so the all-files view can render + * it directly inside Pierre's annotation slot (the header-prefix slot is + * suppressed whenever a custom header is present), while the single-file viewer + * renders a stack of them in {@link FileCommentBanner}. + */ +export const FileCommentCard: React.FC<{ + comment: CodeAnnotation; + isSelected: boolean; + onSelect: (id: string | null) => void; + onEdit: (id: string, text: string) => void; + onDelete: (id: string) => void; +}> = ({ comment, isSelected, onSelect, onEdit, onDelete }) => { + // Default expanded (the comment IS the point of a guided review); the toggle + // lets a reviewer collapse a long note back to one line to reach the hunks. + const [collapsed, setCollapsed] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(comment.text ?? ''); + + // External (source-set) comments are read-only — they mirror how inline + // annotations treat findings imported via the External Annotations API. + const editable = !comment.source; + const html = useMemo( + () => (comment.text ? sanitizeBlockHtml(comment.text) : ''), + [comment.text], + ); + + const saveEdit = () => { + const trimmed = draft.trim(); + if (trimmed && trimmed !== comment.text) onEdit(comment.id, trimmed); + setIsEditing(false); + }; + + return ( +
onSelect(comment.id)} + > + + {/* Collapse toggle — always visible (primary affordance for reclaiming + space from a long comment), unlike the hover-revealed actions. */} + + + + } + conventionalLabel={comment.conventionalLabel} + decorations={comment.decorations} + reviewProfileLabel={comment.reviewProfileLabel} + source={comment.source} + author={comment.author} + createdAt={comment.createdAt} + trailing={ + editable ? ( +
+ {!isEditing && ( + + )} + +
+ ) : undefined + } + /> + + {isEditing ? ( +
e.stopPropagation()}> +