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