Files
backnotprop__plannotator/packages/review-editor/components/FileCommentBanner.tsx
Michael Ramos e1efea8bd4 feat(review): file comments in the diff + unified click-to-highlight comment UX (#973)
* feat(review): file comments in diff + unified click-to-highlight comment UX

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.

* feat(review): unify comment action row (edit · copy · delete)

Shared CommentActions component used by the inline diff card, the file-comment
banner, and the sidebar — bottom-aligned, hover-revealed, order left→right
edit · copy · delete (delete/close furthest right).

- Inline + banner: actions moved from the top (where they overlapped the
  timestamp) to the bottom, and gained the copy button.
- Sidebar: now uses the same component (copy + delete) — same icons, order, and
  styling.
- Read-only (externally-sourced) file comments show copy only.
- Extract commentCopyText() so every card's copy produces identical,
  self-describing text (location prefix + body + reasoning); drops the format
  that was hand-built in the sidebar and the agent-job panel.
- Rename PRCommentsTab's local CommentActions -> PRCommentLinkActions to avoid
  the name clash with the shared component.
- Remove the now-dead .review-comment-action(s) CSS and CommentMeta's trailing
  slot.

* fix(review): address PR review findings

- all-files: let an active compose own the line highlight (pendingSelection
  guard) so clicking/deselecting a comment mid-compose no longer clobbers the
  toolbar's range — mirrors single-file's `pendingSelection ?? annotationRange`.
- clear scrollTargetAnnotation on inline select so a remount (Refresh / base
  switch) doesn't re-scroll back to a stale sidebar/findings navigate target.
- inline diff cards: hide edit/delete for external (source-set) findings
  (matches the file-comment card) and copy with the file:line location prefix
  via a precomputed metadata.copyText (commentCopyText), unifying the copy text.
- add decorations + createdAt to the file-comment refresh signature so an
  external PATCH changing only those repaints the all-files header.
- wrap finding reasoning text (overflow-wrap/word-break) so long file:line refs
  no longer bleed past the card edge.

* refactor(review): dedupe line-annotation projection + close signature gaps

Self-review follow-ups to the PR-feedback fixes:

- Extract lineAnnotationMetadata() — the all-files and single-file diff surfaces
  built the identical 14-field DiffAnnotationMetadata; now one source of truth.
- Add reviewProfileLabel + source + createdAt to the LINE-scope refresh
  signature: the unified CommentMeta now renders those on inline cards too, so
  an external PATCH changing only them must repaint the item (same gap that was
  just fixed for the file-comment tuple).
- Wrap the agent-job panel's finding text + reasoning (overflow-wrap) so long
  file:line refs don't bleed past the panel edge, matching the inline card fix.

* fix(review): zero inter-file gap + stop file-path descender clipping

- all-files: set CodeView layout gap to 0 (Pierre default was 8) so files sit
  flush; sticky headers already mark file boundaries. Keep default edge padding.
- FileHeader: path text leading-none -> leading-normal so the overflow-hidden
  ellipsis box has room for descenders (g/y/p were clipped by the box bottom).

* fix(review): address 2nd-pass review — header re-measure, delete, highlight

- all-files file comments: re-measure the CodeView item when a comment
  expands/collapses/edits (FileCommentCard -> onHeightChange -> refreshItem) so
  Pierre repositions following items instead of overlapping them. Fixes the
  dynamic header-height bug; the remaining static sticky-offset drift is bounded
  by the new banner cap and stays the accepted trade-off.
- cap the expanded comment body (max-h + overflow) so a long guided-review note
  doesn't fill the viewport / inflate the sticky header.
- delete is now available on every surface (inline, banner) including external
  source-set findings; edit stays gated on !source.
- restore the line highlight when a compose ends: the all-files pendingSelection
  effect now re-derives the selected comment's range instead of clearing
  (mirrors single-file's pendingSelection ?? annotationRange).
- add reasoning to the file-comment refresh signature (copyText includes it).
- guard handleNavigateToAnnotation with annotationMatchesPrScope so a stale
  sidebar/finding row can't navigate to an out-of-scope annotation.

* refactor(review): share annotation→highlight derivation + smoother re-measure

Self-review follow-ups:
- Extract lineSelectionForAnnotation() — the compose-end restore and the
  selection-replay effect built the same annotation→CodeViewLineSelection by
  hand; share it so they can't diverge.
- Re-measure file-comment height via useLayoutEffect (before paint) instead of
  useEffect, so an expand/collapse lands without a one-frame overlap.

* fix(review): ignore navigate to missing annotation + re-seed on scope switch

- handleNavigateToAnnotation: return early when the annotation id is gone
  (deleted) as well as out-of-scope, so a stale row can't leave an orphan
  selection (ring with nothing to scroll to). Dismissed agent-panel rows are
  already non-clickable; this is the belt-and-suspenders for any other path.
- fileSetKey now includes prUrl/prDiffScope so a pure layer<->full-stack scope
  switch (same file set) remounts and re-seeds annotations through the current
  scope filter — the incremental sync bails on an unchanged annotations ref and
  can't otherwise detect the filter change.

* fix(review): allow editing external/source findings inline again

Drop the editable = !source gate on inline and file-comment cards. main always
showed edit for external findings (it routes through updateExternalAnnotation),
so gating it was a regression. Edit, delete, and copy are now all available on
every comment surface regardless of source.

* refactor(review): drop redundant null-check after navigate guard

annotation is guaranteed non-null past the missing/out-of-scope early return.
2026-06-26 12:19:49 -07:00

176 lines
7.1 KiB
TypeScript

import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { CodeAnnotation } from '@plannotator/ui/types';
import { sanitizeBlockHtml } from '@plannotator/ui/utils/sanitizeHtml';
import { CommentMeta } from './CommentMeta';
import { CommentActions } from './CommentActions';
import { FileNameChip } from './FileNameChip';
import { commentCopyText } from '../utils/annotationDisplay';
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;
/** Re-measure hook for the virtualized all-files host (see FileCommentCard). */
onHeightChange?: () => 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;
/** Fired after our rendered height changes (expand/collapse/edit) so the
* virtualized all-files host can re-measure this item. */
onHeightChange?: () => void;
}> = ({ comment, isSelected, onSelect, onEdit, onDelete, onHeightChange }) => {
// 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 ?? '');
// Tell the owner to re-measure when our height changes — in the all-files view
// these cards live in Pierre's custom-header portal, whose height isn't auto-
// observed; without this, expanding/editing overlaps the content below.
const mounted = useRef(false);
// Layout effect (before paint) so the re-measure lands without a one-frame
// overlap when the card grows/shrinks.
useLayoutEffect(() => {
if (mounted.current) onHeightChange?.();
else mounted.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collapsed, isEditing]);
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 (
<div
className={`review-comment group${isSelected ? ' is-selected' : ''}`}
data-annotation-id={comment.id}
onClick={() => onSelect(comment.id)}
>
<CommentMeta
leading={
<>
{/* Collapse toggle — always visible (primary affordance for reclaiming
space from a long comment), unlike the hover-revealed actions. */}
<button
className="flex-none -ml-0.5 rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
onClick={(e) => { e.stopPropagation(); setCollapsed((c) => !c); }}
title={collapsed ? 'Expand comment' : 'Collapse comment'}
>
<svg className={`w-3.5 h-3.5 transition-transform ${collapsed ? '' : 'rotate-90'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<FileNameChip path={comment.filePath} />
</>
}
conventionalLabel={comment.conventionalLabel}
decorations={comment.decorations}
reviewProfileLabel={comment.reviewProfileLabel}
source={comment.source}
author={comment.author}
createdAt={comment.createdAt}
/>
{isEditing ? (
<div className="mt-1" onClick={(e) => e.stopPropagation()}>
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') { e.preventDefault(); setIsEditing(false); }
else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); saveEdit(); }
}}
className="w-full min-h-[80px] resize-y rounded border border-border bg-background p-2 text-xs leading-relaxed focus:outline-none focus:ring-1 focus:ring-primary/40"
placeholder="File comment (markdown supported)…"
/>
<div className="mt-1 flex items-center justify-end gap-2">
<button className="text-xs px-2 py-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted" onClick={() => setIsEditing(false)}>
Cancel
</button>
<button className="text-xs px-2 py-1 rounded bg-primary/15 text-primary hover:bg-primary/25" onClick={saveEdit}>
Save
</button>
</div>
</div>
) : comment.text ? (
collapsed ? (
<div className="review-comment-body truncate text-muted-foreground/80">{firstLine(comment.text)}</div>
) : (
<div className="review-comment-body ai-markdown max-h-[220px] overflow-y-auto" dangerouslySetInnerHTML={{ __html: html }} />
)
) : null}
{!isEditing && (
<CommentActions
onEdit={() => { setDraft(comment.text ?? ''); setIsEditing(true); setCollapsed(false); }}
copyText={comment.text ? commentCopyText(comment) : undefined}
onDelete={() => onDelete(comment.id)}
/>
)}
</div>
);
};
/**
* Renders a file's file-scoped comments directly below the file path, above the
* diff hunks. Long comments stay fully visible (no truncation) but can be
* collapsed per-comment. Used in both the single-file viewer (plain React tree)
* and the all-files view (Pierre's `renderHeaderPrefix` portal slot).
*/
export const FileCommentBanner: React.FC<FileCommentBannerProps> = ({
comments,
selectedAnnotationId,
onSelect,
onEdit,
onDelete,
onHeightChange,
}) => {
if (comments.length === 0) return null;
return (
<div className="file-comment-banner flex flex-col px-4 pt-1 pb-1">
{comments.map((comment) => (
<FileCommentCard
key={comment.id}
comment={comment}
isSelected={selectedAnnotationId === comment.id}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onHeightChange={onHeightChange}
/>
))}
</div>
);
};