Skip to content

Commit 702c878

Browse files
authored
feat(review): token-level code selection for annotations (#500)
* feat(review): token-level code selection for annotations Add the ability to click individual syntax tokens in the diff viewer to create annotations with precise code context. Uses Pierre v1.1.12's onTokenClick/onTokenEnter/onTokenLeave callbacks. - Click a token → toolbar opens with token text in header - Click same token again → deselects - Gutter line selection still works independently - Token metadata (charStart, charEnd, tokenText) stored on CodeAnnotation - Sidebar shows token badge, export includes token context For provenance purposes, this commit was AI assisted. * fix(review): token draft roundtrip, export precision, truncation alignment - Persist tokenSelection in drafts so token context survives file switches - Include char offsets in exported feedback for duplicate token disambiguation - Escape backticks in token text to prevent broken markdown fences - Align sidebar token truncation to 30 chars (matching toolbar header) For provenance purposes, this commit was AI assisted.
1 parent b375a80 commit 702c878

9 files changed

Lines changed: 168 additions & 32 deletions

File tree

packages/review-editor/App.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/u
1818
import { getAIProviderSettings, saveAIProviderSettings, getPreferredModel } from '@plannotator/ui/utils/aiProvider';
1919
import { AISetupDialog } from '@plannotator/ui/components/AISetupDialog';
2020
import { needsAISetup } from '@plannotator/ui/utils/aiSetup';
21-
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange } from '@plannotator/ui/types';
21+
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, TokenAnnotationMeta } from '@plannotator/ui/types';
2222
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
2323
import { useCodeAnnotationDraft } from '@plannotator/ui/hooks/useCodeAnnotationDraft';
2424
import { useGitAdd } from './hooks/useGitAdd';
@@ -687,7 +687,8 @@ const ReviewApp: React.FC = () => {
687687
type: CodeAnnotationType,
688688
text?: string,
689689
suggestedCode?: string,
690-
originalCode?: string
690+
originalCode?: string,
691+
tokenMeta?: TokenAnnotationMeta
691692
) => {
692693
if (!pendingSelection || !files[activeFileIndex]) return;
693694

@@ -706,6 +707,11 @@ const ReviewApp: React.FC = () => {
706707
text,
707708
suggestedCode,
708709
originalCode,
710+
...(tokenMeta && {
711+
charStart: tokenMeta.charStart,
712+
charEnd: tokenMeta.charEnd,
713+
tokenText: tokenMeta.tokenText,
714+
}),
709715
createdAt: Date.now(),
710716
author: identity,
711717
};

packages/review-editor/components/AnnotationToolbar.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
22
import { createPortal } from 'react-dom';
33
import { ToolbarState } from '../hooks/useAnnotationToolbar';
44
import { useTabIndent } from '../hooks/useTabIndent';
5-
import { formatLineRange } from '../utils/formatLineRange';
5+
import { formatLineRange, formatTokenContext } from '../utils/formatLineRange';
66
import { AskAIInput } from './AskAIInput';
77
import { SparklesIcon } from './SparklesIcon';
88
import type { AIChatEntry } from '../hooks/useAIChat';
@@ -116,7 +116,11 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
116116
<div className="w-80">
117117
<div className="flex items-center justify-between mb-2" {...dragHandleProps}>
118118
<span className="text-xs text-muted-foreground">
119-
{isEditing ? 'Edit annotation' : formatLineRange(toolbarState.range.start, toolbarState.range.end)}
119+
{isEditing
120+
? 'Edit annotation'
121+
: toolbarState.tokenSelection
122+
? formatTokenContext(toolbarState.tokenSelection)
123+
: formatLineRange(toolbarState.range.start, toolbarState.range.end)}
120124
</span>
121125
<button
122126
onClick={onCancel}

packages/review-editor/components/DiffViewer.tsx

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import React, { useMemo, useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react';
22
import { FileDiff, type DiffLineAnnotation } from '@pierre/diffs/react';
33
import { getSingularPatch, processFile } from '@pierre/diffs';
4-
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata } from '@plannotator/ui/types';
4+
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata, TokenAnnotationMeta } from '@plannotator/ui/types';
5+
import type { DiffTokenEventBaseProps } from '@pierre/diffs';
56
import { useTheme } from '@plannotator/ui/components/ThemeProvider';
67
import { CommentPopover } from '@plannotator/ui/components/CommentPopover';
78
import { storage } from '@plannotator/ui/utils/storage';
@@ -37,6 +38,9 @@ interface PierreDiffContentProps {
3738
onLineSelectionEnd: (range: SelectedLineRange | null) => void;
3839
renderAnnotation: (annotation: { side: string; lineNumber: number; metadata?: DiffAnnotationMetadata }) => React.ReactNode;
3940
renderHoverUtility: (getHoveredLine: () => { lineNumber: number; side: 'deletions' | 'additions' } | undefined) => React.ReactNode;
41+
onTokenClick?: (props: DiffTokenEventBaseProps, event: MouseEvent) => void;
42+
onTokenEnter?: (props: DiffTokenEventBaseProps, event: PointerEvent) => void;
43+
onTokenLeave?: (props: DiffTokenEventBaseProps, event: PointerEvent) => void;
4044
}
4145

4246
const PierreDiffContent = React.memo(({
@@ -54,6 +58,9 @@ const PierreDiffContent = React.memo(({
5458
onLineSelectionEnd,
5559
renderAnnotation,
5660
renderHoverUtility,
61+
onTokenClick,
62+
onTokenEnter,
63+
onTokenLeave,
5764
}: PierreDiffContentProps) => {
5865
return (
5966
<FileDiff
@@ -72,6 +79,9 @@ const PierreDiffContent = React.memo(({
7279
enableLineSelection: true,
7380
enableHoverUtility: true,
7481
onLineSelectionEnd,
82+
onTokenClick,
83+
onTokenEnter,
84+
onTokenLeave,
7585
}}
7686
lineAnnotations={mergedAnnotations}
7787
selectedLines={pendingSelection || undefined}
@@ -94,7 +104,10 @@ const PierreDiffContent = React.memo(({
94104
prev.pendingSelection === next.pendingSelection &&
95105
prev.onLineSelectionEnd === next.onLineSelectionEnd &&
96106
prev.renderAnnotation === next.renderAnnotation &&
97-
prev.renderHoverUtility === next.renderHoverUtility
107+
prev.renderHoverUtility === next.renderHoverUtility &&
108+
prev.onTokenClick === next.onTokenClick &&
109+
prev.onTokenEnter === next.onTokenEnter &&
110+
prev.onTokenLeave === next.onTokenLeave
98111
));
99112

100113
interface DiffViewerProps {
@@ -114,7 +127,7 @@ interface DiffViewerProps {
114127
selectedAnnotationId: string | null;
115128
pendingSelection: SelectedLineRange | null;
116129
onLineSelection: (range: SelectedLineRange | null) => void;
117-
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string) => void;
130+
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, tokenMeta?: TokenAnnotationMeta) => void;
118131
onAddFileComment: (text: string) => void;
119132
onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string) => void;
120133
onSelectAnnotation: (id: string | null) => void;
@@ -441,6 +454,19 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
441454
);
442455
}, [toolbar.handleLineSelectionEnd]);
443456

457+
// Token interaction handlers (code area clicks)
458+
const handleTokenClick = useCallback((props: DiffTokenEventBaseProps, event: MouseEvent) => {
459+
toolbar.handleTokenClick(props, event);
460+
}, [toolbar.handleTokenClick]);
461+
462+
const handleTokenEnter = useCallback((props: DiffTokenEventBaseProps) => {
463+
props.tokenElement.classList.add('pn-token-hover');
464+
}, []);
465+
466+
const handleTokenLeave = useCallback((props: DiffTokenEventBaseProps) => {
467+
props.tokenElement.classList.remove('pn-token-hover');
468+
}, []);
469+
444470
// Inject resolved colors into @pierre/diffs shadow DOM.
445471
// CSS custom properties don't cross the shadow boundary, so we read computed
446472
// values and pass them via unsafeCSS. Single state object avoids split renders.
@@ -452,6 +478,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
452478
const bg = styles.getPropertyValue('--background').trim();
453479
const fg = styles.getPropertyValue('--foreground').trim();
454480
const muted = styles.getPropertyValue('--muted').trim();
481+
const primary = styles.getPropertyValue('--primary').trim();
455482
if (!bg || !fg) return;
456483

457484
const fontCSS = fontFamily || fontSize ? `
@@ -486,6 +513,13 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
486513
[data-diff-type='split'][data-overflow='scroll'] > [data-code][data-additions] [data-content] {
487514
min-width: 0 !important;
488515
}
516+
.pn-token-hover {
517+
text-decoration: underline;
518+
text-decoration-color: ${primary || 'oklch(0.70 0.20 280)'};
519+
text-decoration-thickness: 1.5px;
520+
text-underline-offset: 2px;
521+
cursor: pointer;
522+
}
489523
${fontCSS}
490524
`,
491525
});
@@ -543,6 +577,9 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
543577
onLineSelectionEnd={toolbar.handleLineSelectionEnd}
544578
renderAnnotation={renderAnnotation}
545579
renderHoverUtility={renderHoverUtility}
580+
onTokenClick={handleTokenClick}
581+
onTokenEnter={handleTokenEnter}
582+
onTokenLeave={handleTokenLeave}
546583
/>
547584
</div>
548585
</div>

packages/review-editor/components/ReviewSidebar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,9 @@ export const ReviewSidebar: React.FC<ReviewSidebarProps> = /* React.memo */({
328328
{annotation.lineStart === annotation.lineEnd
329329
? `L${annotation.lineStart}`
330330
: `L${annotation.lineStart}-${annotation.lineEnd}`}
331+
{annotation.tokenText && (
332+
<span className="ml-1 text-primary/70">{`\`${annotation.tokenText.length > 30 ? annotation.tokenText.slice(0, 27) + '...' : annotation.tokenText}\``}</span>
333+
)}
331334
</span>
332335
)}
333336
{annotation.author && (

packages/review-editor/dock/ReviewStateContext.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { createContext, useContext } from 'react';
2-
import type { CodeAnnotation, CodeAnnotationType, SelectedLineRange } from '@plannotator/ui/types';
2+
import type { CodeAnnotation, CodeAnnotationType, SelectedLineRange, TokenAnnotationMeta } from '@plannotator/ui/types';
33
import type { AgentJobInfo } from '@plannotator/ui/types';
44
import type { DiffFile } from '../types';
55
import type { AIChatEntry } from '../hooks/useAIChat';
@@ -33,7 +33,7 @@ export interface ReviewState {
3333
selectedAnnotationId: string | null;
3434
pendingSelection: SelectedLineRange | null;
3535
onLineSelection: (range: SelectedLineRange | null) => void;
36-
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string) => void;
36+
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, tokenMeta?: TokenAnnotationMeta) => void;
3737
onAddFileComment: (text: string) => void;
3838
onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string) => void;
3939
onSelectAnnotation: (id: string | null) => void;

packages/review-editor/hooks/useAnnotationToolbar.ts

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,34 @@
11
import { useState, useCallback, useRef, useEffect } from 'react';
2-
import { CodeAnnotation, SelectedLineRange, CodeAnnotationType } from '@plannotator/ui/types';
2+
import { CodeAnnotation, SelectedLineRange, CodeAnnotationType, TokenAnnotationMeta } from '@plannotator/ui/types';
33
import { useDismissOnOutsideAndEscape } from '@plannotator/ui/hooks/useDismissOnOutsideAndEscape';
44
import { extractLinesFromPatch } from '../utils/patchParser';
5+
import type { DiffTokenEventBaseProps } from '@pierre/diffs';
6+
7+
export interface TokenMeta {
8+
lineNumber: number;
9+
charStart: number;
10+
charEnd: number;
11+
tokenText: string;
12+
side: 'deletions' | 'additions';
13+
}
14+
15+
export interface TokenSelection {
16+
anchor: TokenMeta;
17+
fullText: string;
18+
}
519

620
export interface ToolbarState {
721
position: { top: number; left: number };
822
range: SelectedLineRange;
23+
tokenSelection?: TokenSelection;
924
}
1025

1126
interface UseAnnotationToolbarArgs {
1227
patch: string;
1328
filePath: string;
1429
isFocused: boolean;
1530
onLineSelection: (range: SelectedLineRange | null) => void;
16-
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string) => void;
31+
onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, tokenMeta?: TokenAnnotationMeta) => void;
1732
onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string) => void;
1833
}
1934

@@ -24,6 +39,7 @@ interface Draft {
2439
showSuggestedCode: boolean;
2540
range: SelectedLineRange;
2641
position: { top: number; left: number };
42+
tokenSelection?: TokenSelection;
2743
}
2844

2945
const draftStore = new Map<string, Draft>();
@@ -38,6 +54,7 @@ function draftKey(filePath: string, range: SelectedLineRange): string {
3854
export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelection, onAddAnnotation, onEditAnnotation }: UseAnnotationToolbarArgs) {
3955
const toolbarRef = useRef<HTMLDivElement>(null);
4056
const lastMousePosition = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
57+
const tokenAnchorRef = useRef<TokenMeta | null>(null);
4158

4259
const [toolbarState, setToolbarState] = useState<ToolbarState | null>(null);
4360
const [commentText, setCommentText] = useState('');
@@ -68,6 +85,7 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
6885
...form,
6986
range,
7087
position: toolbarStateRef.current?.position ?? { top: 0, left: 0 },
88+
tokenSelection: toolbarStateRef.current?.tokenSelection,
7189
});
7290
currentDraftKeyRef.current = key;
7391
} else {
@@ -94,6 +112,11 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
94112
return () => saveDraft();
95113
}, [saveDraft]);
96114

115+
// Clear token anchor on file switch
116+
useEffect(() => {
117+
tokenAnchorRef.current = null;
118+
}, [filePath]);
119+
97120
const resetForm = useCallback(() => {
98121
setToolbarState(null);
99122
setCommentText('');
@@ -109,19 +132,15 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
109132
lastMousePosition.current = { x: e.clientX, y: e.clientY };
110133
}, []);
111134

112-
// Handle line selection end
113-
const handleLineSelectionEnd = useCallback((range: SelectedLineRange | null) => {
114-
if (!range) {
115-
setToolbarState(null);
116-
onLineSelection(null);
117-
return;
118-
}
119-
120-
// Save current draft before switching
135+
// Shared: save current draft, restore form for new range, set toolbar state, notify parent
136+
const openToolbar = useCallback((
137+
range: SelectedLineRange,
138+
position: { top: number; left: number },
139+
tokenSelection?: TokenSelection,
140+
) => {
121141
saveDraft();
122142
setEditingAnnotationId(null);
123143

124-
// Restore draft for new range or start fresh
125144
const draft = draftStore.get(draftKey(filePath, range));
126145
if (draft) {
127146
setCommentText(draft.commentText);
@@ -133,18 +152,10 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
133152
setShowSuggestedCode(false);
134153
}
135154

136-
const mousePos = lastMousePosition.current;
137-
setToolbarState({
138-
position: {
139-
top: mousePos.y + 10,
140-
left: mousePos.x,
141-
},
142-
range,
143-
});
155+
setToolbarState({ position, range, tokenSelection });
144156
currentDraftKeyRef.current = draftKey(filePath, range);
145157
restoreDraftKeyByFilePath.delete(filePath);
146158

147-
// Pre-extract original code from selected lines
148159
const side = range.side === 'additions' ? 'new' : 'old';
149160
const start = Math.min(range.start, range.end);
150161
const end = Math.max(range.start, range.end);
@@ -153,6 +164,20 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
153164
onLineSelection(range);
154165
}, [patch, filePath, onLineSelection, saveDraft]);
155166

167+
// Handle line selection end (gutter clicks)
168+
const handleLineSelectionEnd = useCallback((range: SelectedLineRange | null) => {
169+
tokenAnchorRef.current = null;
170+
171+
if (!range) {
172+
setToolbarState(null);
173+
onLineSelection(null);
174+
return;
175+
}
176+
177+
const mousePos = lastMousePosition.current;
178+
openToolbar(range, { top: mousePos.y + 10, left: mousePos.x });
179+
}, [onLineSelection, openToolbar]);
180+
156181
// Handle annotation submission (create or update)
157182
const handleSubmitAnnotation = useCallback(() => {
158183
const hasComment = commentText.trim().length > 0;
@@ -166,7 +191,13 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
166191
if (editingAnnotationId) {
167192
onEditAnnotation(editingAnnotationId, text, code, original);
168193
} else {
169-
onAddAnnotation('comment', text, code, original);
194+
const tokenSel = toolbarState.tokenSelection;
195+
const tokenMeta = tokenSel ? {
196+
charStart: tokenSel.anchor.charStart,
197+
charEnd: tokenSel.anchor.charEnd,
198+
tokenText: tokenSel.fullText,
199+
} : undefined;
200+
onAddAnnotation('comment', text, code, original, tokenMeta);
170201
}
171202

172203
clearDraft();
@@ -239,6 +270,7 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
239270
setToolbarState({
240271
position: draft.position,
241272
range: draft.range,
273+
tokenSelection: draft.tokenSelection,
242274
});
243275
currentDraftKeyRef.current = key;
244276
restoreDraftKeyByFilePath.delete(filePath);
@@ -251,6 +283,35 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
251283
}
252284
}, [filePath, isFocused, onLineSelection, patch]);
253285

286+
// Handle single token click — opens toolbar for one token
287+
const handleTokenClick = useCallback((props: DiffTokenEventBaseProps, event: MouseEvent) => {
288+
const clickedToken: TokenMeta = {
289+
lineNumber: props.lineNumber,
290+
charStart: props.lineCharStart,
291+
charEnd: props.lineCharEnd,
292+
tokenText: props.tokenText,
293+
side: props.side,
294+
};
295+
296+
// Same token clicked twice → deselect
297+
const anchor = tokenAnchorRef.current;
298+
if (anchor && anchor.lineNumber === clickedToken.lineNumber
299+
&& anchor.charStart === clickedToken.charStart
300+
&& anchor.side === clickedToken.side) {
301+
tokenAnchorRef.current = null;
302+
setToolbarState(null);
303+
onLineSelection(null);
304+
return;
305+
}
306+
307+
tokenAnchorRef.current = clickedToken;
308+
openToolbar(
309+
{ start: clickedToken.lineNumber, end: clickedToken.lineNumber, side: clickedToken.side },
310+
{ top: event.clientY + 10, left: event.clientX },
311+
{ anchor: clickedToken, fullText: clickedToken.tokenText },
312+
);
313+
}, [onLineSelection, openToolbar]);
314+
254315
return {
255316
// State
256317
toolbarState,
@@ -271,6 +332,7 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
271332
// Handlers
272333
handleMouseMove,
273334
handleLineSelectionEnd,
335+
handleTokenClick,
274336
handleSubmitAnnotation,
275337
handleDismiss,
276338
handleCancel,

0 commit comments

Comments
 (0)