feat: add Conventional Comments support to code review (#501)

* 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.
This commit is contained in:
Michael Ramos
2026-04-06 22:27:47 -07:00
committed by GitHub
parent 719fd96cad
commit dd04e6b1cd
21 changed files with 816 additions and 32 deletions
+2 -1
View File
@@ -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<string, unknown> };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean };
const toSave: Record<string, unknown> = {};
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<typeof saveConfig>[0]);
json(res, { ok: true });
} catch {
+2 -1
View File
@@ -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<string, unknown> };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean };
const toSave: Record<string, unknown> = {};
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<typeof saveConfig>[0]);
json(res, { ok: true });
} catch {
+2 -1
View File
@@ -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<string, unknown> };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean };
const toSave: Record<string, unknown> = {};
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<typeof saveConfig>[0]);
json(res, { ok: true });
} catch {
+15 -5
View File
@@ -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<CodeAnnotation> = {
...(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\`\`\``;
}
@@ -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<AnnotationToolbarProps> = ({
onSubmit,
onDismiss,
onCancel,
conventionalCommentsEnabled,
conventionalLabel,
onConventionalLabelChange,
decorations,
onDecorationsChange,
enabledLabels,
aiAvailable = false,
onAskAI,
isAILoading = false,
@@ -133,6 +148,16 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
</button>
</div>
{conventionalCommentsEnabled && (
<ConventionalLabelPicker
selected={conventionalLabel}
decorations={decorations}
onSelect={onConventionalLabelChange}
onDecorationsChange={onDecorationsChange}
enabledLabels={enabledLabels}
/>
)}
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
@@ -0,0 +1,161 @@
import React, { useCallback } from 'react';
import type { ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types';
/** Semantic tone — maps to theme CSS variables, not arbitrary hex */
type SemanticTone = 'danger' | 'warn' | 'success' | 'info' | 'neutral';
export interface LabelDef {
label: ConventionalLabel;
display: string;
tone: SemanticTone;
/** Whether the blocking/non-blocking toggle shows in the picker for this label */
showBlockingToggle: boolean;
hint: string;
}
export const CONVENTIONAL_LABELS: LabelDef[] = [
// Everyday — the three you reach for on 90%+ of comments
{ label: 'suggestion', display: 'suggestion', tone: 'info', showBlockingToggle: true, hint: 'Proposes an improvement' },
{ label: 'nitpick', display: 'nit', tone: 'neutral', showBlockingToggle: false, hint: 'Trivial, preference-based' },
{ label: 'question', display: 'question', tone: 'info', showBlockingToggle: true, hint: 'Seeking clarification' },
// High-signal
{ label: 'issue', display: 'issue', tone: 'danger', showBlockingToggle: true, hint: 'A problem that needs addressing' },
{ label: 'praise', display: 'praise', tone: 'success', showBlockingToggle: false, hint: 'Highlight something positive' },
// Soft / meta
{ label: 'thought', display: 'thought', tone: 'neutral', showBlockingToggle: false, hint: 'An idea, not a request' },
{ label: 'note', display: 'note', tone: 'neutral', showBlockingToggle: false, hint: 'Informational, no action needed' },
// Process
{ label: 'todo', display: 'todo', tone: 'warn', showBlockingToggle: true, hint: 'Small, necessary change' },
{ label: 'chore', display: 'chore', tone: 'warn', showBlockingToggle: true, hint: 'Process task (CI, changelog, etc.)' },
];
// ---------------------------------------------------------------------------
// Picker
// ---------------------------------------------------------------------------
/** Resolve which labels to show based on user config (null = all defaults; empty array = user cleared all) */
export function getEnabledLabels(configJson: string | null): LabelDef[] {
if (!configJson) return CONVENTIONAL_LABELS;
try {
const parsed = JSON.parse(configJson) as Array<Record<string, unknown>>;
if (!Array.isArray(parsed)) return CONVENTIONAL_LABELS;
return parsed.map(cfg => {
const builtIn = CONVENTIONAL_LABELS.find(l => l.label === cfg.label);
return {
label: cfg.label as ConventionalLabel,
display: cfg.display as string,
tone: builtIn?.tone || 'neutral',
showBlockingToggle: cfg.blocking === true || cfg.blocking === 'true',
hint: builtIn?.hint || (cfg.display as string),
};
});
} catch {
return CONVENTIONAL_LABELS;
}
}
interface ConventionalLabelPickerProps {
selected: ConventionalLabel | null;
decorations: ConventionalDecoration[];
onSelect: (label: ConventionalLabel | null) => void;
onDecorationsChange: (decorations: ConventionalDecoration[]) => void;
/** Filtered label list from config (defaults to all) */
enabledLabels?: LabelDef[];
}
export const ConventionalLabelPicker: React.FC<ConventionalLabelPickerProps> = ({
selected,
decorations,
onSelect,
onDecorationsChange,
enabledLabels = CONVENTIONAL_LABELS,
}) => {
const handleLabelClick = useCallback((label: ConventionalLabel) => {
if (selected === label) {
onSelect(null);
onDecorationsChange([]);
} else {
onSelect(label);
const def = enabledLabels.find(l => l.label === label);
// If blocking toggle is enabled for this label, default to non-blocking
if (def?.showBlockingToggle) {
onDecorationsChange(['non-blocking']);
} else {
onDecorationsChange([]);
}
}
}, [selected, enabledLabels, onSelect, onDecorationsChange]);
const activeDef = selected ? enabledLabels.find(l => l.label === selected) : undefined;
const isBlocking = decorations.includes('blocking');
const showToggle = activeDef?.showBlockingToggle ?? false;
const toggleBlocking = useCallback(() => {
const cleaned = decorations.filter(d => d !== 'blocking' && d !== 'non-blocking');
onDecorationsChange([...cleaned, isBlocking ? 'non-blocking' : 'blocking']);
}, [decorations, isBlocking, onDecorationsChange]);
return (
<div className="cc-picker">
<div className="cc-row">
{enabledLabels.map((def, idx) => {
const isActive = selected === def.label;
return (
<button
key={`${idx}-${def.label}`}
type="button"
className={`cc-tag cc-tone-${def.tone}${isActive ? ' active' : ''}`}
onClick={() => handleLabelClick(def.label)}
title={def.hint}
>
{def.display}
</button>
);
})}
{/* Blocking toggle — shown when enabled for this label */}
{showToggle && (
<button
type="button"
className={`cc-blocking-toggle ${isBlocking ? 'is-blocking' : ''}`}
onClick={toggleBlocking}
title={isBlocking ? 'Must resolve before merge' : 'Optional, not blocking'}
>
<span className="cc-toggle-track">
<span className="cc-toggle-thumb" />
</span>
{isBlocking ? 'blocking' : 'non-blocking'}
</button>
)}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Badge (inline annotation header)
// ---------------------------------------------------------------------------
export const ConventionalLabelBadge: React.FC<{
label: ConventionalLabel;
decorations?: ConventionalDecoration[];
}> = ({ label, decorations }) => {
const def = CONVENTIONAL_LABELS.find(l => l.label === label);
// Fall back gracefully for custom labels not in the built-in list
const tone = def?.tone || 'neutral';
const display = def?.display || label;
const isBlocking = decorations?.includes('blocking');
const hasDecoration = isBlocking || decorations?.includes('non-blocking');
return (
<span className={`cc-inline-badge cc-tone-${tone}`}>
{display}
{hasDecoration && (
<span className={`cc-inline-dec${isBlocking ? ' cc-inline-dec-blocking' : ''}`}>
{isBlocking ? 'blocking' : 'non-blocking'}
</span>
)}
</span>
);
};
@@ -1,13 +1,15 @@
import React, { useMemo, useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react';
import { FileDiff, type DiffLineAnnotation } from '@pierre/diffs/react';
import { getSingularPatch, processFile } from '@pierre/diffs';
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata, TokenAnnotationMeta } from '@plannotator/ui/types';
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types';
import type { DiffTokenEventBaseProps } from '@pierre/diffs';
import { useTheme } from '@plannotator/ui/components/ThemeProvider';
import { CommentPopover } from '@plannotator/ui/components/CommentPopover';
import { storage } from '@plannotator/ui/utils/storage';
import { detectLanguage } from '../utils/detectLanguage';
import { useAnnotationToolbar } from '../hooks/useAnnotationToolbar';
import { useConfigValue } from '@plannotator/ui/config';
import { getEnabledLabels } from './ConventionalLabelPicker';
import { FileHeader } from './FileHeader';
import { InlineAnnotation } from './InlineAnnotation';
import { InlineAIMarker } from './InlineAIMarker';
@@ -127,9 +129,9 @@ interface DiffViewerProps {
selectedAnnotationId: string | null;
pendingSelection: SelectedLineRange | null;
onLineSelection: (range: SelectedLineRange | null) => void;
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, tokenMeta?: TokenAnnotationMeta) => void;
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel, decorations?: ConventionalDecoration[], tokenMeta?: TokenAnnotationMeta) => void;
onAddFileComment: (text: string) => void;
onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string) => void;
onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel | null, decorations?: ConventionalDecoration[]) => void;
onSelectAnnotation: (id: string | null) => void;
onDeleteAnnotation: (id: string) => void;
isViewed?: boolean;
@@ -252,6 +254,9 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
}, []);
const toolbar = useAnnotationToolbar({ patch, filePath, isFocused, onLineSelection, onAddAnnotation, onEditAnnotation });
const conventionalCommentsEnabled = useConfigValue('conventionalComments');
const conventionalLabelsJson = useConfigValue('conventionalLabels');
const enabledLabels = useMemo(() => getEnabledLabels(conventionalLabelsJson), [conventionalLabelsJson]);
// Parse patch into FileDiffMetadata for @pierre/diffs FileDiff component
const fileDiff = useMemo(() => getSingularPatch(patch), [patch]);
@@ -375,6 +380,8 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
author: ann.author,
severity: ann.severity,
reasoning: ann.reasoning,
conventionalLabel: ann.conventionalLabel,
decorations: ann.decorations,
} as DiffAnnotationMetadata,
}));
}, [annotations]);
@@ -618,6 +625,12 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
onSubmit={toolbar.handleSubmitAnnotation}
onDismiss={toolbar.handleDismiss}
onCancel={toolbar.handleCancel}
conventionalCommentsEnabled={conventionalCommentsEnabled}
conventionalLabel={toolbar.conventionalLabel}
onConventionalLabelChange={toolbar.setConventionalLabel}
decorations={toolbar.decorations}
onDecorationsChange={toolbar.setDecorations}
enabledLabels={enabledLabels}
aiAvailable={aiAvailable}
onAskAI={onAskAI}
isAILoading={isAILoading}
@@ -1,6 +1,7 @@
import React from 'react';
import { SEVERITY_STYLES, DiffAnnotationMetadata } from '@plannotator/ui/types';
import { SuggestionBlock } from './SuggestionBlock';
import { ConventionalLabelBadge } from './ConventionalLabelPicker';
import { renderInlineMarkdown } from '../utils/renderInlineMarkdown';
interface InlineAnnotationProps {
@@ -32,6 +33,9 @@ export const InlineAnnotation: React.FC<InlineAnnotationProps> = ({
{severity && (
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${severity.dot}`} title={severity.label} />
)}
{metadata.conventionalLabel && (
<ConventionalLabelBadge label={metadata.conventionalLabel} decorations={metadata.decorations} />
)}
{metadata.author && <span className="text-xs text-muted-foreground">{metadata.author}</span>}
</div>
<div className="review-comment-actions">
@@ -3,6 +3,7 @@ import { CodeAnnotation, type EditorAnnotation } from '@plannotator/ui/types';
import { isCurrentUser } from '@plannotator/ui/utils/identity';
import { EditorAnnotationCard } from '@plannotator/ui/components/EditorAnnotationCard';
import { CopyButton } from './CopyButton';
import { ConventionalLabelBadge } from './ConventionalLabelPicker';
import { HighlightedCode } from './HighlightedCode';
import { detectLanguage } from '../utils/detectLanguage';
import { renderInlineMarkdown } from '../utils/renderInlineMarkdown';
@@ -333,6 +334,9 @@ export const ReviewSidebar: React.FC<ReviewSidebarProps> = /* React.memo */({
)}
</span>
)}
{annotation.conventionalLabel && (
<ConventionalLabelBadge label={annotation.conventionalLabel} decorations={annotation.decorations} />
)}
{annotation.author && (
<span className={`text-[10px] truncate max-w-[100px] ${isCurrentUser(annotation.author) ? 'text-muted-foreground/50' : 'text-muted-foreground/70'}`}>
{annotation.author}{isCurrentUser(annotation.author) && ' (me)'}
@@ -1,5 +1,5 @@
import React, { createContext, useContext } from 'react';
import type { CodeAnnotation, CodeAnnotationType, SelectedLineRange, TokenAnnotationMeta } from '@plannotator/ui/types';
import type { CodeAnnotation, CodeAnnotationType, SelectedLineRange, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types';
import type { AgentJobInfo } from '@plannotator/ui/types';
import type { DiffFile } from '../types';
import type { AIChatEntry } from '../hooks/useAIChat';
@@ -33,9 +33,9 @@ export interface ReviewState {
selectedAnnotationId: string | null;
pendingSelection: SelectedLineRange | null;
onLineSelection: (range: SelectedLineRange | null) => void;
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, tokenMeta?: TokenAnnotationMeta) => void;
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel, decorations?: ConventionalDecoration[], tokenMeta?: TokenAnnotationMeta) => void;
onAddFileComment: (text: string) => void;
onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string) => void;
onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel | null, decorations?: ConventionalDecoration[]) => void;
onSelectAnnotation: (id: string | null) => void;
onDeleteAnnotation: (id: string) => void;
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { CodeAnnotation, SelectedLineRange, CodeAnnotationType, TokenAnnotationMeta } from '@plannotator/ui/types';
import { CodeAnnotation, SelectedLineRange, CodeAnnotationType, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types';
import { useDismissOnOutsideAndEscape } from '@plannotator/ui/hooks/useDismissOnOutsideAndEscape';
import { extractLinesFromPatch } from '../utils/patchParser';
import type { DiffTokenEventBaseProps } from '@pierre/diffs';
@@ -28,8 +28,8 @@ interface UseAnnotationToolbarArgs {
filePath: string;
isFocused: boolean;
onLineSelection: (range: SelectedLineRange | null) => void;
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, tokenMeta?: TokenAnnotationMeta) => void;
onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string) => void;
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel, decorations?: ConventionalDecoration[], tokenMeta?: TokenAnnotationMeta) => void;
onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel | null, decorations?: ConventionalDecoration[]) => void;
}
// Per-range draft storage (survives component remounts, e.g. file switches)
@@ -37,6 +37,8 @@ interface Draft {
commentText: string;
suggestedCode: string;
showSuggestedCode: boolean;
conventionalLabel: ConventionalLabel | null;
decorations: ConventionalDecoration[];
range: SelectedLineRange;
position: { top: number; left: number };
tokenSelection?: TokenSelection;
@@ -64,10 +66,12 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
const [showCodeModal, setShowCodeModal] = useState(false);
const [modalLayout, setModalLayout] = useState<'horizontal' | 'vertical'>('horizontal');
const [editingAnnotationId, setEditingAnnotationId] = useState<string | null>(null);
const [conventionalLabel, setConventionalLabel] = useState<ConventionalLabel | null>(null);
const [decorations, setDecorations] = useState<ConventionalDecoration[]>([]);
// Refs to avoid stale closures in saveDraft
const formRef = useRef({ commentText, suggestedCode, showSuggestedCode });
formRef.current = { commentText, suggestedCode, showSuggestedCode };
const formRef = useRef({ commentText, suggestedCode, showSuggestedCode, conventionalLabel, decorations });
formRef.current = { commentText, suggestedCode, showSuggestedCode, conventionalLabel, decorations };
const toolbarStateRef = useRef(toolbarState);
toolbarStateRef.current = toolbarState;
const editingRef = useRef(editingAnnotationId);
@@ -80,7 +84,7 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
if (!range || editingRef.current) return;
const form = formRef.current;
const key = draftKey(filePath, range);
if (form.commentText.trim() || form.suggestedCode.trim()) {
if (form.commentText.trim() || form.suggestedCode.trim() || form.conventionalLabel) {
draftStore.set(key, {
...form,
range,
@@ -125,6 +129,8 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
setShowSuggestedCode(false);
setShowCodeModal(false);
setEditingAnnotationId(null);
setConventionalLabel(null);
setDecorations([]);
}, []);
// Track mouse position continuously for toolbar placement
@@ -146,10 +152,14 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
setCommentText(draft.commentText);
setSuggestedCode(draft.suggestedCode);
setShowSuggestedCode(draft.showSuggestedCode);
setConventionalLabel(draft.conventionalLabel);
setDecorations(draft.decorations);
} else {
setCommentText('');
setSuggestedCode('');
setShowSuggestedCode(false);
setConventionalLabel(null);
setDecorations([]);
}
setToolbarState({ position, range, tokenSelection });
@@ -189,7 +199,8 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
const original = hasCode && selectedOriginalCode ? selectedOriginalCode : undefined;
if (editingAnnotationId) {
onEditAnnotation(editingAnnotationId, text, code, original);
// Edit path: pass null explicitly so a cleared label is removed from the annotation
onEditAnnotation(editingAnnotationId, text, code, original, conventionalLabel, decorations);
} else {
const tokenSel = toolbarState.tokenSelection;
const tokenMeta = tokenSel ? {
@@ -197,12 +208,20 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
charEnd: tokenSel.anchor.charEnd,
tokenText: tokenSel.fullText,
} : undefined;
onAddAnnotation('comment', text, code, original, tokenMeta);
onAddAnnotation(
'comment',
text,
code,
original,
conventionalLabel ?? undefined,
decorations.length > 0 ? decorations : undefined,
tokenMeta,
);
}
clearDraft();
resetForm();
}, [toolbarState, commentText, suggestedCode, selectedOriginalCode, editingAnnotationId, onAddAnnotation, onEditAnnotation, clearDraft, resetForm]);
}, [toolbarState, commentText, suggestedCode, selectedOriginalCode, editingAnnotationId, conventionalLabel, decorations, onAddAnnotation, onEditAnnotation, clearDraft, resetForm]);
// Start editing an existing annotation
const startEdit = useCallback((annotation: CodeAnnotation) => {
@@ -212,6 +231,8 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
setSelectedOriginalCode(annotation.originalCode || '');
setShowSuggestedCode(!!annotation.suggestedCode);
setShowCodeModal(false);
setConventionalLabel(annotation.conventionalLabel || null);
setDecorations(annotation.decorations || []);
// Position toolbar near the annotation using last known mouse position
const mousePos = lastMousePosition.current;
@@ -265,6 +286,8 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
setCommentText(draft.commentText);
setSuggestedCode(draft.suggestedCode);
setShowSuggestedCode(draft.showSuggestedCode);
setConventionalLabel(draft.conventionalLabel);
setDecorations(draft.decorations);
setEditingAnnotationId(null);
setShowCodeModal(false);
setToolbarState({
@@ -327,6 +350,10 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
modalLayout,
setModalLayout,
editingAnnotationId,
conventionalLabel,
setConventionalLabel,
decorations,
setDecorations,
// Refs
toolbarRef,
// Handlers
+216
View File
@@ -325,6 +325,222 @@ diffs-container {
background: oklch(from var(--primary) l c h / 0.3);
}
/* =====================================================
Conventional Comments — Diagnostic-style labels
Visual language: VS Code severity indicators.
Monospace, lowercase, theme-native semantic colors.
===================================================== */
.cc-picker {
margin-bottom: 0.5rem;
}
/* Label row — single line, no wrap, compact diagnostic tags */
.cc-row {
display: flex;
flex-wrap: wrap;
gap: 1px;
}
/* Individual label tag — monospace, tight, code-native */
.cc-tag {
font-family: var(--font-mono);
font-size: 0.5625rem;
font-weight: 500;
line-height: 1;
letter-spacing: 0.01em;
padding: 0.1875rem 0.375rem;
border-radius: calc(var(--radius-sm) - 1px);
border: 1px solid transparent;
color: var(--muted-foreground);
background: transparent;
cursor: pointer;
white-space: nowrap;
transition: color 0ms, background 0ms, border-color 0ms;
user-select: none;
}
/* Hover: instant on */
.cc-tag:hover {
color: var(--foreground);
background: var(--muted);
}
/* Ease off on leave */
.cc-tag:not(:hover) {
transition: color 150ms ease-out, background 150ms ease-out, border-color 150ms ease-out;
}
/* Active press feedback */
.cc-tag:active {
transform: scale(0.97);
}
/* --- Semantic tone colors (active state only) --- */
.cc-tag.active {
font-weight: 600;
border-color: oklch(from var(--muted-foreground) l c h / 0.2);
}
.cc-tag.cc-tone-danger.active {
color: var(--destructive);
background: oklch(from var(--destructive) l c h / 0.1);
border-color: oklch(from var(--destructive) l c h / 0.2);
}
.cc-tag.cc-tone-warn.active {
color: var(--warning);
background: oklch(from var(--warning) l c h / 0.1);
border-color: oklch(from var(--warning) l c h / 0.2);
}
.cc-tag.cc-tone-success.active {
color: var(--success);
background: oklch(from var(--success) l c h / 0.1);
border-color: oklch(from var(--success) l c h / 0.2);
}
.cc-tag.cc-tone-info.active {
color: var(--primary);
background: oklch(from var(--primary) l c h / 0.1);
border-color: oklch(from var(--primary) l c h / 0.2);
}
.cc-tag.cc-tone-neutral.active {
color: var(--foreground);
background: var(--muted);
border-color: var(--border);
}
/* --- Blocking toggle (only for issue & suggestion) --- */
.cc-blocking-toggle {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
margin-left: 0.25rem;
padding: 0.125rem 0.375rem 0.125rem 0.25rem;
font-family: var(--font-mono);
font-size: 0.5625rem;
font-weight: 500;
letter-spacing: 0.01em;
line-height: 1;
color: var(--muted-foreground);
background: transparent;
border: none;
border-left: 1px solid var(--border);
border-radius: 0;
cursor: pointer;
user-select: none;
transition: color 150ms ease-out;
}
.cc-blocking-toggle:hover {
color: var(--foreground);
}
/* Mini toggle track */
.cc-toggle-track {
position: relative;
width: 1.125rem;
height: 0.625rem;
border-radius: 9999px;
background: var(--muted);
border: 1px solid var(--border);
flex-shrink: 0;
transition: background 150ms ease-out, border-color 150ms ease-out;
}
.cc-toggle-thumb {
position: absolute;
top: 1px;
left: 1px;
width: 0.375rem;
height: 0.375rem;
border-radius: 9999px;
background: var(--muted-foreground);
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1), background 150ms ease-out;
}
/* Blocking ON state */
.cc-blocking-toggle.is-blocking .cc-toggle-track {
background: oklch(from var(--destructive) l c h / 0.15);
border-color: oklch(from var(--destructive) l c h / 0.3);
}
.cc-blocking-toggle.is-blocking .cc-toggle-thumb {
transform: translateX(0.4375rem);
background: var(--destructive);
}
.cc-blocking-toggle.is-blocking {
color: var(--destructive);
}
/* --- Inline badge on rendered annotation comments --- */
.cc-inline-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: var(--font-mono);
font-size: 0.5625rem;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1;
padding: 0.125rem 0.3125rem;
border-radius: 2px;
border-left: 2px solid;
white-space: nowrap;
}
.cc-inline-badge.cc-tone-danger {
color: var(--destructive);
background: oklch(from var(--destructive) l c h / 0.08);
border-left-color: var(--destructive);
}
.cc-inline-badge.cc-tone-warn {
color: var(--warning);
background: oklch(from var(--warning) l c h / 0.08);
border-left-color: var(--warning);
}
.cc-inline-badge.cc-tone-success {
color: var(--success);
background: oklch(from var(--success) l c h / 0.08);
border-left-color: var(--success);
}
.cc-inline-badge.cc-tone-info {
color: var(--primary);
background: oklch(from var(--primary) l c h / 0.08);
border-left-color: var(--primary);
}
.cc-inline-badge.cc-tone-neutral {
color: var(--muted-foreground);
background: oklch(from var(--muted-foreground) l c h / 0.08);
border-left-color: var(--muted-foreground);
}
.cc-inline-dec {
opacity: 0.6;
font-weight: 500;
}
.cc-inline-dec::before {
content: '·';
margin-right: 0.125rem;
opacity: 0.4;
}
.cc-inline-dec.cc-inline-dec-blocking {
opacity: 0.9;
color: var(--destructive);
}
/* Annotation toolbar for code review */
.review-toolbar {
position: absolute;
+23 -3
View File
@@ -1,7 +1,21 @@
import type { CodeAnnotation } from '@plannotator/ui/types';
import type { CodeAnnotation, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types';
import type { PRMetadata } from '@plannotator/shared/pr-provider';
import { getMRLabel, getMRNumberLabel, getDisplayRepo } from '@plannotator/shared/pr-provider';
/**
* Format a conventional comment prefix per the Conventional Comments spec:
* `**label (decorations):** ` — entire label+decorations+colon wrapped in bold.
* See https://conventionalcomments.org for examples.
*/
export function formatConventionalPrefix(
label?: ConventionalLabel,
decorations?: ConventionalDecoration[],
): string {
if (!label) return '';
const decs = decorations?.length ? ` (${decorations.join(', ')})` : '';
return `**${label}${decs}:** `;
}
/**
* Build markdown feedback from code review annotations.
*
@@ -46,11 +60,15 @@ export function exportReviewFeedback(
const ann = sorted[i];
const scope = ann.scope ?? 'line';
const prefix = formatConventionalPrefix(ann.conventionalLabel, ann.decorations);
if (scope === 'file') {
output += `### File Comment\n`;
if (ann.text) {
output += `${ann.text}\n`;
output += `${prefix}${ann.text}\n`;
} else if (prefix) {
output += `${prefix.trimEnd()}\n`;
}
if (ann.suggestedCode) {
@@ -71,7 +89,9 @@ export function exportReviewFeedback(
output += `### ${lineRange} (${ann.side})${tokenSuffix}\n`;
if (ann.text) {
output += `${ann.text}\n`;
output += `${prefix}${ann.text}\n`;
} else if (prefix) {
output += `${prefix.trimEnd()}\n`;
}
if (ann.reasoning) {
+3 -1
View File
@@ -150,10 +150,12 @@ export async function startAnnotateServer(
// API: Update user config (write-back to ~/.plannotator/config.json)
if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown> };
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null };
const toSave: Record<string, unknown> = {};
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 (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels;
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
return Response.json({ ok: true });
} catch {
+3 -1
View File
@@ -284,10 +284,12 @@ export async function startPlannotatorServer(
// API: Update user config (write-back to ~/.plannotator/config.json)
if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown> };
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null };
const toSave: Record<string, unknown> = {};
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 (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels;
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
return Response.json({ ok: true });
} catch {
+3 -1
View File
@@ -509,10 +509,12 @@ export async function startReviewServer(
// API: Update user config (write-back to ~/.plannotator/config.json)
if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown> };
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null };
const toSave: Record<string, unknown> = {};
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 (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels;
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
return Response.json({ ok: true });
} catch {
+24 -2
View File
@@ -21,9 +21,19 @@ export interface DiffOptions {
fontSize?: string;
}
/** Single conventional comment label entry stored in config.json */
export interface CCLabelConfig {
label: string;
display: string;
blocking: boolean;
}
export interface PlannotatorConfig {
displayName?: string;
diffOptions?: DiffOptions;
conventionalComments?: boolean;
/** null = explicitly cleared (use defaults), undefined = not set */
conventionalLabels?: CCLabelConfig[] | null;
}
const CONFIG_DIR = join(homedir(), ".plannotator");
@@ -80,7 +90,19 @@ export function detectGitUser(): string | null {
* Build the serverConfig payload for API responses.
* Reads config.json fresh each call so the response reflects the latest file on disk.
*/
export function getServerConfig(gitUser: string | null): { displayName?: string; diffOptions?: DiffOptions; gitUser?: string } {
export function getServerConfig(gitUser: string | null): {
displayName?: string;
diffOptions?: DiffOptions;
gitUser?: string;
conventionalComments?: boolean;
conventionalLabels?: CCLabelConfig[] | null;
} {
const cfg = loadConfig();
return { displayName: cfg.displayName, diffOptions: cfg.diffOptions, gitUser: gitUser ?? undefined };
return {
displayName: cfg.displayName,
diffOptions: cfg.diffOptions,
gitUser: gitUser ?? undefined,
...(cfg.conventionalComments !== undefined && { conventionalComments: cfg.conventionalComments }),
...(cfg.conventionalLabels !== undefined && { conventionalLabels: cfg.conventionalLabels }),
};
}
+212 -1
View File
@@ -69,7 +69,7 @@ import {
type FileBrowserSettings,
} from '../utils/fileBrowser';
type SettingsTab = 'general' | 'theme' | 'display' | 'saving' | 'labels' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine';
type SettingsTab = 'general' | 'theme' | 'display' | 'saving' | 'labels' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments';
interface SettingsProps {
taterMode: boolean;
@@ -315,6 +315,211 @@ const ReviewDisplayTab: React.FC = () => {
label="Show Diff Background"
description="Colored backgrounds on added/deleted lines"
/>
</>
);
};
/**
* Stored label config — serialized to JSON in the config store.
* `blocking` controls whether the blocking/non-blocking toggle
* appears in the picker when this label is selected.
*/
interface CCLabelConfig {
label: string;
display: string;
blocking: boolean; // true = show toggle in picker, false = no decoration
}
const DEFAULT_CC_LABELS: CCLabelConfig[] = [
{ label: 'suggestion', display: 'suggestion', blocking: true },
{ label: 'nitpick', display: 'nit', blocking: false },
{ label: 'question', display: 'question', blocking: true },
{ label: 'issue', display: 'issue', blocking: true },
{ label: 'praise', display: 'praise', blocking: false },
{ label: 'thought', display: 'thought', blocking: false },
{ label: 'note', display: 'note', blocking: false },
{ label: 'todo', display: 'todo', blocking: true },
{ label: 'chore', display: 'chore', blocking: true },
];
function parseCCLabels(json: string | null): CCLabelConfig[] {
if (!json) return DEFAULT_CC_LABELS;
try {
const parsed = JSON.parse(json);
if (!Array.isArray(parsed)) return DEFAULT_CC_LABELS;
return parsed.map((l: Record<string, unknown>) => ({
label: (l.label as string) || 'custom',
display: (l.display as string) || (l.label as string) || 'custom',
blocking: l.blocking === true || l.blocking === 'true',
}));
} catch {
return DEFAULT_CC_LABELS;
}
}
const CommentsTab: React.FC = () => {
const conventionalComments = useConfigValue('conventionalComments');
const labelsJson = useConfigValue('conventionalLabels');
const [labels, setLabels] = useState(() => parseCCLabels(labelsJson));
const isDefault = labelsJson === null;
const save = (next: CCLabelConfig[]) => {
setLabels(next);
configStore.set('conventionalLabels', JSON.stringify(next));
};
const updateLabel = (index: number, updates: Partial<CCLabelConfig>) => {
const next = [...labels];
next[index] = { ...next[index], ...updates };
save(next);
};
const removeLabel = (index: number) => {
save(labels.filter((_, i) => i !== index));
};
const addLabel = () => {
const existing = new Set(labels.map(l => l.label));
let slug = 'custom';
let n = 2;
while (existing.has(slug)) { slug = `custom-${n++}`; }
save([...labels, { label: slug, display: slug, blocking: false }]);
};
const resetToDefaults = () => {
setLabels(DEFAULT_CC_LABELS);
configStore.set('conventionalLabels', null);
};
return (
<>
<ToggleSwitch
checked={conventionalComments}
onChange={(v) => configStore.set('conventionalComments', v)}
label="Conventional Comments"
description="Add structured labels to review comments"
/>
<div className={`space-y-4 transition-opacity ${conventionalComments ? '' : 'opacity-40 pointer-events-none'}`}>
<div className="border-t border-border" />
{/* How it works */}
<div className="space-y-3">
<div>
<div className="text-sm font-medium">How it works</div>
<div className="text-xs text-muted-foreground leading-relaxed mt-1">
When enabled, a label picker appears above the comment input when annotating code.
Labels classify your feedback intent, making it clear whether a comment is a blocking issue,
a trivial nitpick, or praise.
</div>
</div>
<div className="text-xs text-muted-foreground leading-relaxed">
Based on the{' '}
<a
href="https://conventionalcomments.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Conventional Comments
</a>
{' '}spec. Comments are exported as plain text, readable on GitHub, and parseable by tooling.
</div>
{/* Example output */}
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-1.5">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Example output</div>
<div className="font-mono text-[11px] text-foreground/80 leading-relaxed">
<span className="font-bold">issue</span> <span className="text-muted-foreground">(blocking)</span>: This will throw if user is null — the guard was removed in the refactor.
</div>
</div>
</div>
<div className="border-t border-border" />
{/* Label editor */}
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Labels</div>
<div className="text-xs text-muted-foreground">
Customize labels and their default severity
</div>
</div>
{!isDefault && (
<button
onClick={resetToDefaults}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
Reset to defaults
</button>
)}
</div>
{/* Column headers */}
<div className="flex items-end gap-2 px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
<span className="flex-1">Label</span>
<span className="w-20 text-center">Blocking decorator</span>
<span className="w-6" />
</div>
<div className="space-y-1.5">
{labels.map((label, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 rounded-lg bg-muted/40"
>
<input
type="text"
value={label.display}
onChange={(e) => {
const display = e.target.value;
updateLabel(index, { display, label: display });
}}
className="flex-1 px-2 py-1 bg-background/80 rounded text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50 min-w-0"
/>
<div className="w-20 flex justify-center">
<button
role="switch"
aria-checked={label.blocking}
onClick={() => updateLabel(index, { blocking: !label.blocking })}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
label.blocking ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow-sm transition-transform ${
label.blocking ? 'translate-x-4.5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<button
onClick={() => removeLabel(index)}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
title="Remove label"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
{labels.length < 12 && (
<button
onClick={addLabel}
className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border border-dashed border-border rounded-lg hover:border-foreground/30 transition-colors"
>
+ Add label
</button>
)}
</div>
</>
);
};
@@ -363,6 +568,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
}
if (mode === 'review') {
t.push({ id: 'display', label: 'Display' });
t.push({ id: 'comments', label: 'Comments' });
if (aiProviders.length > 0) {
t.push({ id: 'ai', label: 'AI' });
}
@@ -1343,6 +1549,11 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
<KeyboardShortcuts mode={mode} />
)}
{/* === COMMENTS TAB === */}
{activeTab === 'comments' && (
<CommentsTab />
)}
{/* === AI TAB === */}
{activeTab === 'ai' && (
<AISettingsTab
+39
View File
@@ -148,6 +148,45 @@ export const SETTINGS = {
},
toServer: (v: string) => ({ diffOptions: { fontSize: v } }),
},
conventionalComments: {
defaultValue: false as boolean,
fromCookie: () => {
const v = storage.getItem('plannotator-conventional-comments');
return v === 'true' ? true : v === 'false' ? false : undefined;
},
toCookie: (v: boolean) => storage.setItem('plannotator-conventional-comments', String(v)),
serverKey: 'conventionalComments',
fromServer: (sc: Record<string, unknown>) => {
const v = sc.conventionalComments;
return typeof v === 'boolean' ? v : undefined;
},
toServer: (v: boolean) => ({ conventionalComments: v }),
},
/** JSON-serialized array of label configs, or null for defaults.
* Synced to ~/.plannotator/config.json as a parsed array (not a string). */
conventionalLabels: {
defaultValue: null as string | null,
fromCookie: () => storage.getItem('plannotator-cc-labels') || undefined,
toCookie: (v: string | null) => {
if (v) storage.setItem('plannotator-cc-labels', v);
else storage.removeItem('plannotator-cc-labels');
},
serverKey: 'conventionalLabels',
fromServer: (sc: Record<string, unknown>) => {
const v = sc.conventionalLabels;
if (v === null) return null;
if (Array.isArray(v)) return JSON.stringify(v);
return undefined;
},
toServer: (v: string | null) => {
if (v === null) return { conventionalLabels: null };
try {
return { conventionalLabels: JSON.parse(v) };
} catch {
return {};
}
},
},
} satisfies Record<string, SettingDef<unknown>>;
export type SettingsMap = typeof SETTINGS;
+1
View File
@@ -85,6 +85,7 @@ body {
linear-gradient(to bottom, oklch(0.90 0.01 260 / 0.6) 1px, transparent 1px);
}
/* Glow effects */
.glow-primary {
box-shadow: 0 0 20px oklch(from var(--primary) l c h / 0.15), 0 0 40px oklch(from var(--primary) l c h / 0.05);
+22
View File
@@ -62,6 +62,24 @@ export interface DiffResult {
export type CodeAnnotationType = 'comment' | 'suggestion' | 'concern';
export type CodeAnnotationScope = 'line' | 'file';
/** Conventional Comments label — see https://conventionalcomments.org */
export type ConventionalLabel =
| 'praise'
| 'nitpick'
| 'suggestion'
| 'issue'
| 'todo'
| 'question'
| 'thought'
| 'chore'
| 'note'
| 'typo'
| 'polish'
| (string & {}); // Allow custom labels while preserving autocomplete for built-ins
/** Conventional Comments decoration (parenthesized modifier) */
export type ConventionalDecoration = 'blocking' | 'non-blocking' | 'if-minor';
export interface CodeAnnotation {
id: string;
type: CodeAnnotationType;
@@ -81,6 +99,8 @@ export interface CodeAnnotation {
source?: string; // External tool identifier (e.g., "eslint") — set when annotation comes from external API
severity?: 'important' | 'nit' | 'pre_existing'; // Agent review severity (Claude)
reasoning?: string; // Validation chain — how the issue was confirmed (Claude)
conventionalLabel?: ConventionalLabel;
decorations?: ConventionalDecoration[];
}
/** Token-level metadata passed from selection to annotation creation. */
@@ -107,6 +127,8 @@ export interface DiffAnnotationMetadata {
author?: string;
severity?: 'important' | 'nit' | 'pre_existing';
reasoning?: string;
conventionalLabel?: ConventionalLabel;
decorations?: ConventionalDecoration[];
// AI marker fields (set when kind === 'ai-marker')
kind?: 'annotation' | 'ai-marker';
questionId?: string;