feat(review): sidebar "+ General comment" — the durable review-level comment producer (PR4)

Spec §3.3: scope:'general' annotations already render, badge, and export;
the sole producer was Call Flow. This adds the human one — the community's
"integrated global comment field" ask.

- ReviewSidebar gains optional onAddGeneralComment. The SAME button renders
  in the General section header (which now renders whenever the callback is
  present, even with zero general comments) AND in the all-empty state — the
  state the affordance is most useful in. The composer is the shared
  DecisionNoteField in a small anchored popover (the third consumer the
  branches' extraction tripwire named); the draft survives a dismissal, an
  empty commit refocuses the field (the decision-composer contract).
- Both human producers now share one shape factory,
  createGeneralReviewComment in reviewDecision.ts: scope:'general',
  sentinel filePath ''/0/0, review-note-${randomUUID()} id, and NO PR
  context — an unstamped annotation passes every PR scope predicate
  (utils/annotationScope.ts), so the comment survives an in-place PR switch.
- Unlike the header composer's one-submit note, the sidebar comment goes
  through addCodeAnnotationsWithHistory: undoable, draft-persisted, and
  deletable via the sidebar's existing delete. Creating one raises
  totalAnnotationCount, which flips the header control to Send Feedback · n
  — the control is state-driven by construction.

No server change (spec §6.1): the comment rides the existing /api/feedback
annotations array and the export's ## General section on both runtimes.
This commit is contained in:
Michael Ramos
2026-09-02 10:08:29 -07:00
parent 84dbdfc98a
commit ee440759f1
3 changed files with 164 additions and 25 deletions
+19 -18
View File
@@ -18,6 +18,7 @@ import { DecisionControl, DecisionNoteDialog, type DecisionHandler } from '@plan
import {
compactPrimaryIdForReviewDecision,
compactRowIdForReviewDecisionItem,
createGeneralReviewComment,
resolveReviewDecisionAction,
REVIEW_APPROVAL_NOTES_SUPPORTED,
} from './reviewDecision';
@@ -3631,30 +3632,29 @@ const ReviewApp: React.FC = () => {
// Note → scope:'general' CodeAnnotation at submit time: it rides the
// existing export (## General) and the /api/feedback annotations array with
// no server change on either runtime (#1449 transport). Sentinel
// filePath ''/0/0 keeps it out of every file group; deliberately NOT
// recorded in review history (it lives for one submit) and NOT stamped with
// PR context, so it survives an in-place PR switch.
// no server change on either runtime (#1449 transport). Shape (sentinels,
// no PR context) lives in createGeneralReviewComment; deliberately NOT
// recorded in review history it lives for one submit.
const commitReviewNote = useCallback((text: string): string | null => {
const trimmed = text.trim();
if (!trimmed) return null;
const note: CodeAnnotation = {
id: `review-note-${crypto.randomUUID()}`,
type: 'comment',
scope: 'general',
filePath: '',
lineStart: 0,
lineEnd: 0,
side: 'new',
text: trimmed,
createdAt: Date.now(),
...(identity ? { author: identity } : {}),
};
const note = createGeneralReviewComment(text, identity);
if (!note) return null;
annotationsRef.current = [...annotationsRef.current, note];
setAnnotations(annotationsRef.current);
return note.id;
}, [identity]);
// Sidebar "+ General comment" — the durable human producer for a
// scope:'general' review-level comment (spec §3.3). Unlike the submit note
// above, it goes through history (undoable, draft-persisted, deletable via
// the sidebar's existing delete); like it, it is deliberately NOT
// withPRContext-stamped, so it survives an in-place PR switch (see the
// factory's doc in reviewDecision.ts).
const handleAddGeneralComment = useCallback((text: string) => {
const note = createGeneralReviewComment(text, identity);
if (!note) return;
addCodeAnnotationsWithHistory([note]);
}, [identity, addCodeAnnotationsWithHistory]);
// The commit above is a state write, so feedbackMarkdown/handleSendFeedback
// (which close over `allAnnotations`) only see the note on the NEXT render.
// Submit from an effect once the note is actually in state. One automatic
@@ -5060,6 +5060,7 @@ const ReviewApp: React.FC = () => {
onSelectAnnotation={handleSelectAnnotation}
onNavigateToAnnotation={handleNavigateToAnnotation}
onDeleteAnnotation={handleDeleteAnnotation}
onAddGeneralComment={handleAddGeneralComment}
feedbackMarkdown={feedbackMarkdown}
width={isCompactTouchLayout ? undefined : panelResize.width}
editorAnnotations={visibleEditorAnnotations}
@@ -1,5 +1,9 @@
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import { CodeAnnotation, type CodeAnnotationScope, type EditorAnnotation, type Annotation, type CommentAnnotation } from '@plannotator/ui/types';
import { Button } from '@plannotator/ui/components/ui/button';
import { DecisionNoteField } from '@plannotator/ui/components/DecisionControl';
import { useDismissablePopover } from '@plannotator/ui/hooks/useDismissablePopover';
import { submitHint } from '@plannotator/ui/utils/platform';
import { CommentMeta } from './CommentMeta';
import { EditorAnnotationCard } from '@plannotator/ui/components/EditorAnnotationCard';
import { CommentActions } from './CommentActions';
@@ -34,6 +38,11 @@ interface ReviewSidebarProps {
/** Sidebar row click → select AND scroll the diff to the comment. */
onNavigateToAnnotation: (id: string | null) => void;
onDeleteAnnotation: (id: string) => void;
/** "+ General comment": commit a durable scope:'general' review-level
* comment to the session (spec §3.3). When present, the affordance renders
* in the General section header AND in the all-empty state — the state it
* is most useful in. */
onAddGeneralComment?: (text: string) => void;
feedbackMarkdown?: string;
width?: number;
editorAnnotations?: EditorAnnotation[];
@@ -109,6 +118,78 @@ const SuggestionPreview: React.FC<{ code: string; originalCode?: string; languag
);
};
/**
* "+ General comment" — the human producer for a durable review-level comment
* (the sole producer before this was Call Flow). The SAME button renders in
* both placements (General section header, all-empty state); the composer is
* the shared `DecisionNoteField` in a small anchored popover — the third
* consumer of the note field, which is why it is a separate export from
* `DecisionControl`. The draft survives a dismissal (outside click / Escape);
* only a commit clears it. An empty commit never fires the callback — it
* refocuses the field, the same contract as the decision composers.
*/
const GeneralCommentComposer: React.FC<{
onAdd: (text: string) => void;
/** Popover alignment relative to the button: section header anchors right,
* the centered empty-state button anchors center. */
align: 'right' | 'center';
touchTarget?: boolean;
}> = ({ onAdd, align, touchTarget }) => {
const [open, setOpen] = useState(false);
const [text, setText] = useState('');
const ref = useRef<HTMLDivElement>(null);
useDismissablePopover({ enabled: open, ref, onDismiss: () => setOpen(false) });
const submit = () => {
const trimmed = text.trim();
if (!trimmed) {
ref.current?.querySelector<HTMLTextAreaElement>('[data-decision-note-input]')?.focus();
return;
}
onAdd(trimmed);
setText('');
setOpen(false);
};
return (
<div ref={ref} className="relative" data-review-general-composer={open ? 'open' : 'closed'}>
<button
type="button"
data-pn-touch-target={touchTarget || undefined}
data-add-general-comment
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="dialog"
title="Add a review-level comment"
className="inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
+ General comment
</button>
{open && (
<div
className={`absolute top-full z-30 mt-1 w-64 max-w-[calc(100vw-2rem)] rounded-lg border border-border bg-popover p-2 shadow-xl ${
align === 'right' ? 'right-0' : 'left-1/2 -translate-x-1/2'
}`}
>
<DecisionNoteField
text={text}
onTextChange={setText}
onSubmit={submit}
onCancel={() => setOpen(false)}
placeholder="Add a general comment..."
/>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-[10px] leading-snug text-muted-foreground">{submitHint}</span>
<Button size="xs" data-general-comment-add onClick={submit} title="Add the comment to this review">
Add comment
</Button>
</div>
</div>
)}
</div>
);
};
const SCOPE_ORDER = { general: 0, file: 1, line: 2 } as const;
function getAnnotationScope(annotation: CodeAnnotation): CodeAnnotationScope {
@@ -140,6 +221,7 @@ export const ReviewSidebar: React.FC<ReviewSidebarProps> = /* React.memo */({
onSelectAnnotation,
onNavigateToAnnotation,
onDeleteAnnotation,
onAddGeneralComment,
feedbackMarkdown,
width,
editorAnnotations,
@@ -464,17 +546,38 @@ export const ReviewSidebar: React.FC<ReviewSidebarProps> = /* React.memo */({
<p className="text-xs text-muted-foreground">
{presentation === 'overlay' ? 'Tap a line to add an annotation' : 'Click on lines to add annotations'}
</p>
{onAddGeneralComment && (
<div className="mt-3">
<GeneralCommentComposer
onAdd={onAddGeneralComment}
align="center"
touchTarget={presentation === 'overlay'}
/>
</div>
)}
</div>
) : (
<div className="p-2 space-y-4">
{generalAnnotations.length > 0 && (
{(generalAnnotations.length > 0 || onAddGeneralComment) && (
<div>
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm px-2 py-1 text-xs font-medium text-muted-foreground">
General
</div>
<div className="space-y-1">
{generalAnnotations.map((annotation) => renderAnnotationCard(annotation))}
{/* z above the file/PR sticky headers (z-10/z-20) so the
anchored composer popover is never painted under a
later section's header. */}
<div className="sticky top-0 z-[25] bg-background/95 backdrop-blur-sm px-2 py-1 flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">General</span>
{onAddGeneralComment && (
<GeneralCommentComposer
onAdd={onAddGeneralComment}
align="right"
touchTarget={presentation === 'overlay'}
/>
)}
</div>
{generalAnnotations.length > 0 && (
<div className="space-y-1">
{generalAnnotations.map((annotation) => renderAnnotationCard(annotation))}
</div>
)}
</div>
)}
{isMultiPR && prGroups ? (
+35
View File
@@ -1,4 +1,5 @@
import type { DecisionActionId, DecisionMenuItem, DecisionPrimary } from '@plannotator/ui/utils/decisionSpec';
import type { CodeAnnotation } from '@plannotator/ui/types';
import type { CompactReviewAction } from './components/ReviewHeaderMenu';
/**
@@ -87,3 +88,37 @@ export function compactPrimaryIdForReviewDecision(
): Extract<CompactReviewAction['id'], 'feedback' | 'approve'> {
return primary.icon === 'send' ? 'feedback' : 'approve';
}
/**
* The one shape for a human review-level comment: `scope: 'general'` with the
* ''/0/0 sentinels that keep it out of every file group. Shared by BOTH human
* producers — the header composer's submit note (`commitReviewNote`) and the
* sidebar's durable "+ General comment" — so the transport shape the
* review-note payload tests pin cannot fork between them.
*
* Deliberately carries no PR context (`prUrl`/`diffScope`): an unstamped
* annotation passes every PR scope (`utils/annotationScope.ts`), which is
* what lets a review-level comment survive an in-place PR switch (spec §3.3).
* `crypto.randomUUID()` rather than `Date.now()` because two commits in the
* same millisecond would collide and the deferred-submit effect keys on the
* id (spec §9).
*
* Returns null for a whitespace-only note: the composers never commit an
* empty comment.
*/
export function createGeneralReviewComment(text: string, author?: string): CodeAnnotation | null {
const trimmed = text.trim();
if (!trimmed) return null;
return {
id: `review-note-${crypto.randomUUID()}`,
type: 'comment',
scope: 'general',
filePath: '',
lineStart: 0,
lineEnd: 0,
side: 'new',
text: trimmed,
createdAt: Date.now(),
...(author ? { author } : {}),
};
}