Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/pi-extension/server/serverAnnotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion apps/pi-extension/server/serverPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion apps/pi-extension/server/serverReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,10 +565,11 @@ export async function startReviewServer(options: {
json(res, result);
} 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 {
Expand Down
20 changes: 15 additions & 5 deletions packages/review-editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -714,6 +716,8 @@ const ReviewApp: React.FC = () => {
}),
createdAt: Date.now(),
author: identity,
conventionalLabel,
decorations,
};

setAnnotations(prev => [...prev, newAnnotation]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1122,7 +1131,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\`\`\``;
}
Expand Down
25 changes: 25 additions & 0 deletions packages/review-editor/components/AnnotationToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand All @@ -48,6 +57,12 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
onSubmit,
onDismiss,
onCancel,
conventionalCommentsEnabled,
conventionalLabel,
onConventionalLabelChange,
decorations,
onDecorationsChange,
enabledLabels,
aiAvailable = false,
onAskAI,
isAILoading = false,
Expand Down Expand Up @@ -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)}
Expand Down
161 changes: 161 additions & 0 deletions packages/review-editor/components/ConventionalLabelPicker.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
19 changes: 16 additions & 3 deletions packages/review-editor/components/DiffViewer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -605,6 +612,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}
Expand Down
4 changes: 4 additions & 0 deletions packages/review-editor/components/InlineAnnotation.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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">
Expand Down
Loading