From dd04e6b1cde51f1aeca978b4792c22c8f814a57e Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 6 Apr 2026 22:27:47 -0700 Subject: [PATCH] feat: add Conventional Comments support to code review (#501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Conventional Comments support to code review Add structured label system based on the Conventional Comments spec (conventionalcomments.org) to the code review annotation toolbar. - Label picker with 9 default labels (suggestion, nit, question, issue, praise, thought, note, todo, chore) shown above the comment textarea - Per-label blocking/non-blocking toggle for labels where severity is ambiguous (issue, suggestion, todo, chore) - Labels exported as `**label** (decoration): text` in markdown feedback, naturally readable on GitHub and machine-parseable - Label badges shown on inline annotations and sidebar cards - Settings tab (Comments) with: master on/off toggle, editable label list with add/remove/rename, per-label blocking decorator toggle, educational intro with spec link and example output - Feature is off by default, persisted via config store - Custom labels supported via settings (up to 12) For provenance purposes, this commit was AI assisted. * fix(review): address conventional comments review feedback Fix label clearing bug where editing an annotation to remove its conventional label silently preserved the old value. Fix parseCCLabels accepting 'non-blocking' as a truthy blocking value. Fix duplicate custom label identities when adding multiple labels. Prepend conventional prefix to platform PR/MR inline comments. For provenance purposes, this commit was AI assisted. * fix(server): persist conventionalComments setting to config.json All six server config handlers (bun plan/review/annotate + pi plan/review/annotate) now accept and save the conventionalComments boolean, so the setting survives server restarts. For provenance purposes, this commit was AI assisted. * fix: add conventionalComments to server config round-trip getServerConfig() was not returning the conventionalComments value from config.json, so the setting silently reset to false on cookie clear or new browser sessions. Also adds the field to the PlannotatorConfig interface so saveConfig() is properly typed. For provenance purposes, this commit was AI assisted. * fix: label display, export, and draft persistence for conventional comments - Remove slug derivation from label editing — store display text verbatim so badges and exports show what the user typed - Emit conventional prefix in export even when annotation has no prose text (suggestion-only annotations were losing their label) - Include conventionalLabel in draft save condition so label selection persists across file switches For provenance purposes, this commit was AI assisted. * fix: spec-compliant export format and empty label support - Wrap label + decorations + colon in markdown bold per the Conventional Comments spec examples (was bolding only the label) - Allow users to clear all labels — empty array no longer falls back to defaults in either the picker or the settings parser - Enable blocking decorator on the question label by default; the spec permits blocking questions and they sometimes are critical For provenance purposes, this commit was AI assisted. * fix: server-sync conventionalLabels and small cleanups - Sync conventionalLabels to ~/.plannotator/config.json so custom label configurations survive cookie clears and remote/SSH sessions, matching the persistence story for conventionalComments. Stored as a parsed array on disk; serialized as JSON string in the local config store to match the cookie format. - Remove no-op intermediate variable in handleSubmitAnnotation; pass conventionalLabel/decorations directly with proper coalescing per call path (edit preserves null, add coalesces to undefined). - Use index-prefixed key in the picker label list to avoid React warnings if duplicate labels ever exist. For provenance purposes, this commit was AI assisted. --- apps/pi-extension/server/serverAnnotate.ts | 3 +- apps/pi-extension/server/serverPlan.ts | 3 +- apps/pi-extension/server/serverReview.ts | 3 +- packages/review-editor/App.tsx | 20 +- .../components/AnnotationToolbar.tsx | 25 ++ .../components/ConventionalLabelPicker.tsx | 161 +++++++++++++ .../review-editor/components/DiffViewer.tsx | 19 +- .../components/InlineAnnotation.tsx | 4 + .../components/ReviewSidebar.tsx | 4 + .../review-editor/dock/ReviewStateContext.tsx | 6 +- .../hooks/useAnnotationToolbar.ts | 45 +++- packages/review-editor/index.css | 216 ++++++++++++++++++ .../review-editor/utils/exportFeedback.ts | 26 ++- packages/server/annotate.ts | 4 +- packages/server/index.ts | 4 +- packages/server/review.ts | 4 +- packages/shared/config.ts | 26 ++- packages/ui/components/Settings.tsx | 213 ++++++++++++++++- packages/ui/config/settings.ts | 39 ++++ packages/ui/theme.css | 1 + packages/ui/types.ts | 22 ++ 21 files changed, 816 insertions(+), 32 deletions(-) create mode 100644 packages/review-editor/components/ConventionalLabelPicker.tsx diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 6934afa5..e2c6e296 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -84,10 +84,11 @@ export async function startAnnotateServer(options: { }); } else if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record }; + const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; + if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); json(res, { ok: true }); } catch { diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index dbda9252..78448877 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -225,10 +225,11 @@ export async function startPlanReviewServer(options: { } } else if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record }; + const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; + if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); json(res, { ok: true }); } catch { diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 4a909f4f..6d2c6939 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -589,10 +589,11 @@ export async function startReviewServer(options: { json(res, { error: "No file access available" }, 400); } else if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record }; + const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; + if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); json(res, { ok: true }); } catch { diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index b18dc8c5..a752d628 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -18,7 +18,7 @@ import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/u import { getAIProviderSettings, saveAIProviderSettings, getPreferredModel } from '@plannotator/ui/utils/aiProvider'; import { AISetupDialog } from '@plannotator/ui/components/AISetupDialog'; import { needsAISetup } from '@plannotator/ui/utils/aiSetup'; -import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, TokenAnnotationMeta } from '@plannotator/ui/types'; +import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types'; import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; import { useCodeAnnotationDraft } from '@plannotator/ui/hooks/useCodeAnnotationDraft'; import { useGitAdd } from './hooks/useGitAdd'; @@ -36,7 +36,7 @@ import { ReviewHeaderMenu } from './components/ReviewHeaderMenu'; import { ReviewSidebar } from './components/ReviewSidebar'; import { FileTree } from './components/FileTree'; import { DEMO_DIFF } from './demoData'; -import { exportReviewFeedback } from './utils/exportFeedback'; +import { exportReviewFeedback, formatConventionalPrefix } from './utils/exportFeedback'; import { ReviewStateProvider, type ReviewState } from './dock/ReviewStateContext'; import { JobLogsProvider } from './dock/JobLogsContext'; import { reviewPanelComponents } from './dock/reviewPanelComponents'; @@ -688,6 +688,8 @@ const ReviewApp: React.FC = () => { text?: string, suggestedCode?: string, originalCode?: string, + conventionalLabel?: ConventionalLabel, + decorations?: ConventionalDecoration[], tokenMeta?: TokenAnnotationMeta ) => { if (!pendingSelection || !files[activeFileIndex]) return; @@ -714,6 +716,8 @@ const ReviewApp: React.FC = () => { }), createdAt: Date.now(), author: identity, + conventionalLabel, + decorations, }; setAnnotations(prev => [...prev, newAnnotation]); @@ -746,13 +750,18 @@ const ReviewApp: React.FC = () => { id: string, text?: string, suggestedCode?: string, - originalCode?: string + originalCode?: string, + conventionalLabel?: ConventionalLabel | null, + decorations?: ConventionalDecoration[], ) => { const ann = allAnnotationsRef.current.find(a => a.id === id); - const updates = { + const updates: Partial = { ...(text !== undefined && { text }), ...(suggestedCode !== undefined && { suggestedCode }), ...(originalCode !== undefined && { originalCode }), + // null clears the label; undefined means "not provided, keep existing" + ...(conventionalLabel !== undefined && { conventionalLabel: conventionalLabel ?? undefined }), + ...(decorations !== undefined && { decorations }), }; if (ann?.source && externalAnnotations.some(e => e.id === id)) { updateExternalAnnotation(id, updates); @@ -1124,7 +1133,8 @@ const ReviewApp: React.FC = () => { // Inline file comments const fileComments = fileAnnotations.map(ann => { - let commentBody = ann.text ?? ''; + const ccPrefix = formatConventionalPrefix(ann.conventionalLabel, ann.decorations); + let commentBody = ccPrefix + (ann.text ?? ''); if (ann.suggestedCode) { commentBody += `\n\n\`\`\`suggestion\n${ann.suggestedCode}\n\`\`\``; } diff --git a/packages/review-editor/components/AnnotationToolbar.tsx b/packages/review-editor/components/AnnotationToolbar.tsx index 012eb00f..3cfb6c8c 100644 --- a/packages/review-editor/components/AnnotationToolbar.tsx +++ b/packages/review-editor/components/AnnotationToolbar.tsx @@ -5,6 +5,8 @@ import { useTabIndent } from '../hooks/useTabIndent'; import { formatLineRange, formatTokenContext } from '../utils/formatLineRange'; import { AskAIInput } from './AskAIInput'; import { SparklesIcon } from './SparklesIcon'; +import { ConventionalLabelPicker, type LabelDef } from './ConventionalLabelPicker'; +import type { ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types'; import type { AIChatEntry } from '../hooks/useAIChat'; import { useDraggable } from '@plannotator/ui/hooks/useDraggable'; @@ -23,6 +25,13 @@ interface AnnotationToolbarProps { onSubmit: () => void; onDismiss: () => void; onCancel: () => void; + // Conventional Comments + conventionalCommentsEnabled: boolean; + conventionalLabel: ConventionalLabel | null; + onConventionalLabelChange: (label: ConventionalLabel | null) => void; + decorations: ConventionalDecoration[]; + onDecorationsChange: (decorations: ConventionalDecoration[]) => void; + enabledLabels?: LabelDef[]; // AI props aiAvailable?: boolean; onAskAI?: (question: string) => void; @@ -48,6 +57,12 @@ export const AnnotationToolbar: React.FC = ({ onSubmit, onDismiss, onCancel, + conventionalCommentsEnabled, + conventionalLabel, + onConventionalLabelChange, + decorations, + onDecorationsChange, + enabledLabels, aiAvailable = false, onAskAI, isAILoading = false, @@ -133,6 +148,16 @@ export const AnnotationToolbar: React.FC = ({ + {conventionalCommentsEnabled && ( + + )} +