mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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 ? (
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user