Files
backnotprop__plannotator/packages/review-editor/utils/annotationDisplay.ts
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

60 lines
2.2 KiB
TypeScript

import type { CodeAnnotation, CodeAnnotationScope, DiffAnnotationMetadata } from '@plannotator/ui/types';
/** A code annotation's scope, defaulting to 'line' for older/external data. */
export function annotationScope(a: CodeAnnotation): CodeAnnotationScope {
return a.scope ?? 'line';
}
/**
* The location prefix for an annotation's copied text. General comments belong
* to no file, so they carry no prefix; file comments carry just the path; line
* comments carry path + line range. Never emits the "" / 0 sentinels that stand
* in for "no file / no line" on file and general comments.
*/
export function copyLocationPrefix(
a: CodeAnnotation,
scope: CodeAnnotationScope = annotationScope(a),
): string {
if (scope === 'general') return '';
if (scope === 'file') return `${a.filePath}\n`;
return `${a.filePath}:${a.lineStart}${a.lineEnd !== a.lineStart ? `-${a.lineEnd}` : ''}\n`;
}
/**
* The full clipboard text for a comment: location prefix + body + reasoning.
* Shared by every comment card's copy action so they produce identical,
* self-describing text.
*/
export function commentCopyText(
a: CodeAnnotation,
scope: CodeAnnotationScope = annotationScope(a),
): string {
return `${copyLocationPrefix(a, scope)}${a.text ?? ''}${a.reasoning ? `\n\nReasoning: ${a.reasoning}` : ''}`;
}
/**
* Project a line-scoped CodeAnnotation into the @pierre/diffs line-annotation
* metadata the inline card renders. Single source of truth so the all-files and
* single-file diff surfaces stay in lockstep (and so the per-file refresh
* signature can't drift from what's actually rendered). Whenever a field is
* added here, mirror it into the signature builders that gate updateItem().
*/
export function lineAnnotationMetadata(ann: CodeAnnotation): DiffAnnotationMetadata {
return {
annotationId: ann.id,
type: ann.type,
text: ann.text,
suggestedCode: ann.suggestedCode,
originalCode: ann.originalCode,
author: ann.author,
severity: ann.severity,
reasoning: ann.reasoning,
conventionalLabel: ann.conventionalLabel,
decorations: ann.decorations,
createdAt: ann.createdAt,
reviewProfileLabel: ann.reviewProfileLabel,
source: ann.source,
copyText: ann.text ? commentCopyText(ann) : undefined,
};
}