Skip to content

Commit 9a23c24

Browse files
grubmanItayclaudebacknotprop
authored
feat: add quick label selection mode for one-click annotations (#272)
* feat: add quick annotation labels for one-click preset feedback Add preset label chips (Needs tests, Security concern, Break this up, etc.) that allow instant annotation without typing. Includes ⚡ toolbar button, Alt+1..8 keyboard shortcuts, label customization in Settings, and label summary in export output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add quick label selection mode for one-click annotations * feat: redesign quick label picker UX and add label tips - Redesign FloatingQuickLabelPicker as a vertical context-menu style list with cursor-anchored positioning (appears at mouseup point, not selection center) - Unify label dropdown: toolbar and quick-label mode now share the same FloatingQuickLabelPicker component (removed duplicate InlineQuickLabelDropdown) - Fix above/below flip positioning (follow CommentPopover pattern with conditional translateY) - Add label tips: optional instruction text on QuickLabel that gets injected into agent feedback as a blockquote below the label - Add tip editor in Settings with three visual states (empty/editing/filled) - Add "Missing overview" default label with a tip for requesting narrative context - Extend keyboard shortcuts from Alt+1-8 to Alt+1-9 - Suppress input method toggle (Alt) when label picker is open - Reorder default labels: Clarify this, Needs tests, Consider edge cases, Missing overview, Security concern, Break this up, Wrong order, Discuss first, Nice approach Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: curate default labels, add cyan/amber colors, bare digit shortcuts Finalize the 10 default quick labels based on user feedback data: clarify, overview, verify, example, patterns, alternatives, regression, out-of-scope, tests, nice-approach. Each label gets a unique color (added cyan and amber to the palette). Bare digit keys (1-0) now apply labels when the picker is open, Alt+N still works everywhere. Tip editor cursor starts at beginning for readability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent duplicate annotation when digit key fires both toolbar and picker handlers When the quick label picker is open from the toolbar's zap button, let FloatingQuickLabelPicker own all keyboard input instead of both components handling the same keypress. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Michael Ramos <mdramos8@gmail.com>
1 parent e51abc6 commit 9a23c24

13 files changed

Lines changed: 481 additions & 119 deletions

bun.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/ui/components/AnnotationToolbar.tsx

Lines changed: 27 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import React, { useState, useEffect, useRef, useMemo } from "react";
1+
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
22
import { AnnotationType } from "../types";
33
import { createPortal } from "react-dom";
44
import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
5-
import { type QuickLabel, getQuickLabels, getLabelColors } from "../utils/quickLabels";
5+
import { type QuickLabel, getQuickLabels } from "../utils/quickLabels";
6+
import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
67

78
type PositionMode = 'center-above' | 'top-right';
89

@@ -50,6 +51,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
5051
const [copied, setCopied] = useState(false);
5152
const [showQuickLabels, setShowQuickLabels] = useState(false);
5253
const toolbarRef = useRef<HTMLDivElement>(null);
54+
const zapButtonRef = useRef<HTMLButtonElement>(null);
5355
const quickLabels = useMemo(() => getQuickLabels(), []);
5456

5557
const handleCopy = async () => {
@@ -101,21 +103,26 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
101103
};
102104
}, [element, positionMode, closeOnScrollOut, onClose]);
103105

104-
// Type-to-comment + Alt+N quick label shortcuts
106+
// Type-to-comment + Alt+N / bare digit quick label shortcuts
105107
useEffect(() => {
106108
const handleKeyDown = (e: KeyboardEvent) => {
107109
if (e.isComposing) return;
108110
if (isEditableElement(e.target) || isEditableElement(document.activeElement)) return;
111+
112+
// When picker is open, let FloatingQuickLabelPicker own all keyboard input
113+
if (showQuickLabels) return;
114+
109115
if (e.key === "Escape") {
110-
setShowQuickLabels(false);
111116
onClose();
112117
return;
113118
}
114119

115-
// Alt+1..8: apply quick label
116-
if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit8') {
120+
// Alt+N applies quick label (picker closed)
121+
const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0';
122+
if (isDigit && !e.ctrlKey && !e.metaKey && e.altKey) {
117123
e.preventDefault();
118-
const index = parseInt(e.code.slice(5), 10) - 1;
124+
const digit = parseInt(e.code.slice(5), 10);
125+
const index = digit === 0 ? 9 : digit - 1;
119126
if (index < quickLabels.length) {
120127
onQuickLabel?.(quickLabels[index]);
121128
}
@@ -131,10 +138,10 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
131138

132139
window.addEventListener("keydown", handleKeyDown);
133140
return () => window.removeEventListener("keydown", handleKeyDown);
134-
}, [onClose, onRequestComment, onQuickLabel, quickLabels]);
141+
}, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels]);
135142

136143
useDismissOnOutsideAndEscape({
137-
enabled: true,
144+
enabled: !showQuickLabels,
138145
ref: toolbarRef,
139146
onDismiss: onClose,
140147
});
@@ -202,23 +209,25 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
202209
className="text-accent hover:bg-accent/10"
203210
/>
204211
{onQuickLabel && (
205-
<div className="relative">
212+
<>
206213
<ToolbarButton
214+
ref={zapButtonRef}
207215
onClick={() => setShowQuickLabels(prev => !prev)}
208216
icon={<ZapIcon />}
209217
label="Quick label"
210218
className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"}
211219
/>
212-
{showQuickLabels && (
213-
<QuickLabelDropdown
214-
labels={quickLabels}
220+
{showQuickLabels && zapButtonRef.current && (
221+
<FloatingQuickLabelPicker
222+
anchorEl={zapButtonRef.current}
215223
onSelect={(label) => {
216224
setShowQuickLabels(false);
217225
onQuickLabel(label);
218226
}}
227+
onDismiss={() => setShowQuickLabels(false)}
219228
/>
220229
)}
221-
</div>
230+
</>
222231
)}
223232
<div className="w-px h-5 bg-border mx-0.5" />
224233
<ToolbarButton
@@ -233,45 +242,6 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
233242
);
234243
};
235244

236-
// Quick Label Dropdown
237-
const QuickLabelDropdown: React.FC<{
238-
labels: QuickLabel[];
239-
onSelect: (label: QuickLabel) => void;
240-
}> = ({ labels, onSelect }) => {
241-
const isMac = navigator.platform?.includes('Mac');
242-
const altKey = isMac ? '⌥' : 'Alt+';
243-
244-
return (
245-
<div
246-
className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 bg-popover border border-border rounded-lg shadow-2xl p-2 min-w-[220px] z-[101]"
247-
style={{ animation: 'annotation-toolbar-in 0.1s ease-out' }}
248-
onMouseDown={(e) => e.stopPropagation()}
249-
>
250-
<div className="text-[10px] text-muted-foreground/60 px-1 mb-1.5 font-medium uppercase tracking-wide">Quick Labels</div>
251-
<div className="flex flex-wrap gap-1">
252-
{labels.map((label, index) => {
253-
const colors = getLabelColors(label.color);
254-
return (
255-
<button
256-
key={label.id}
257-
onClick={() => onSelect(label)}
258-
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition-opacity hover:opacity-75 active:opacity-60"
259-
style={{ backgroundColor: colors.bg, color: colors.text }}
260-
title={index < 8 ? `${altKey}${index + 1}` : undefined}
261-
>
262-
<span>{label.emoji}</span>
263-
<span>{label.text}</span>
264-
{index < 8 && (
265-
<span className="text-[9px] opacity-40 ml-0.5">{index + 1}</span>
266-
)}
267-
</button>
268-
);
269-
})}
270-
</div>
271-
</div>
272-
);
273-
};
274-
275245
// Icons
276246
const CopyIcon = () => (
277247
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -309,17 +279,18 @@ const CloseIcon = () => (
309279
</svg>
310280
);
311281

312-
const ToolbarButton: React.FC<{
282+
const ToolbarButton = React.forwardRef<HTMLButtonElement, {
313283
onClick: () => void;
314284
icon: React.ReactNode;
315285
label: string;
316286
className: string;
317-
}> = ({ onClick, icon, label, className }) => (
287+
}>(({ onClick, icon, label, className }, ref) => (
318288
<button
289+
ref={ref}
319290
onClick={onClick}
320291
title={label}
321292
className={`p-1.5 rounded-md transition-colors ${className}`}
322293
>
323294
{icon}
324295
</button>
325-
);
296+
));

packages/ui/components/AnnotationToolstrip.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,18 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
104104
</svg>
105105
}
106106
/>
107+
<ToolstripButton
108+
active={mode === 'quickLabel'}
109+
onClick={() => onModeChange('quickLabel')}
110+
label="Label"
111+
color="warning"
112+
mounted={mounted}
113+
icon={
114+
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
115+
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
116+
</svg>
117+
}
118+
/>
107119
</div>
108120

109121
{/* Help */}
@@ -203,6 +215,11 @@ const colorStyles = {
203215
hover: 'text-destructive/80 bg-destructive/8',
204216
inactive: 'text-muted-foreground hover:text-foreground',
205217
},
218+
warning: {
219+
active: 'bg-background text-foreground shadow-sm',
220+
hover: 'text-amber-500/80 bg-amber-500/8',
221+
inactive: 'text-muted-foreground hover:text-foreground',
222+
},
206223
} as const;
207224

208225
type ButtonColor = keyof typeof colorStyles;
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import React, { useState, useEffect, useRef, useMemo } from 'react';
2+
import { createPortal } from 'react-dom';
3+
import { type QuickLabel, getQuickLabels } from '../utils/quickLabels';
4+
import { QuickLabelDropdown } from './QuickLabelDropdown';
5+
6+
interface FloatingQuickLabelPickerProps {
7+
anchorEl: HTMLElement;
8+
/** Mouse coordinates at the moment of selection — picker appears here */
9+
cursorHint?: { x: number; y: number };
10+
onSelect: (label: QuickLabel) => void;
11+
onDismiss: () => void;
12+
}
13+
14+
const PICKER_WIDTH = 192;
15+
const GAP = 6;
16+
const VIEWPORT_PADDING = 12;
17+
18+
function computePosition(
19+
anchorEl: HTMLElement,
20+
cursorHint?: { x: number; y: number },
21+
): { top: number; left: number; flipAbove: boolean } {
22+
const rect = anchorEl.getBoundingClientRect();
23+
24+
// Vertical: use anchor rect for above/below decision + placement
25+
const spaceBelow = window.innerHeight - rect.bottom;
26+
const flipAbove = spaceBelow < 220;
27+
const top = flipAbove ? rect.top - GAP : rect.bottom + GAP;
28+
29+
// Horizontal: prefer cursor x, fallback to anchor right edge
30+
let left: number;
31+
if (cursorHint) {
32+
// Anchor left edge of picker at cursor x, nudge left slightly so
33+
// the first row's text is directly under the pointer
34+
left = cursorHint.x - 28;
35+
} else {
36+
// Fallback: right edge of anchor (where selection likely ended)
37+
left = rect.right - PICKER_WIDTH / 2;
38+
}
39+
40+
// Clamp to viewport
41+
left = Math.max(VIEWPORT_PADDING, Math.min(left, window.innerWidth - PICKER_WIDTH - VIEWPORT_PADDING));
42+
43+
return { top, left, flipAbove };
44+
}
45+
46+
export const FloatingQuickLabelPicker: React.FC<FloatingQuickLabelPickerProps> = ({
47+
anchorEl,
48+
cursorHint,
49+
onSelect,
50+
onDismiss,
51+
}) => {
52+
const [position, setPosition] = useState<{ top: number; left: number; flipAbove: boolean } | null>(null);
53+
const ref = useRef<HTMLDivElement>(null);
54+
const quickLabels = useMemo(() => getQuickLabels(), []);
55+
56+
// Position tracking
57+
useEffect(() => {
58+
const update = () => setPosition(computePosition(anchorEl, cursorHint));
59+
update();
60+
window.addEventListener('scroll', update, true);
61+
window.addEventListener('resize', update);
62+
return () => {
63+
window.removeEventListener('scroll', update, true);
64+
window.removeEventListener('resize', update);
65+
};
66+
}, [anchorEl, cursorHint]);
67+
68+
// Keyboard: 1-9/0 or Alt+1-9/0 to apply label, Escape to dismiss
69+
useEffect(() => {
70+
const handleKeyDown = (e: KeyboardEvent) => {
71+
if (e.key === 'Escape') {
72+
e.preventDefault();
73+
onDismiss();
74+
return;
75+
}
76+
// Accept bare digit or Alt+digit — picker is open so digits mean labels
77+
const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0';
78+
if (isDigit && !e.ctrlKey && !e.metaKey) {
79+
e.preventDefault();
80+
const digit = parseInt(e.code.slice(5), 10);
81+
const index = digit === 0 ? 9 : digit - 1;
82+
if (index < quickLabels.length) {
83+
onSelect(quickLabels[index]);
84+
}
85+
}
86+
};
87+
window.addEventListener('keydown', handleKeyDown);
88+
return () => window.removeEventListener('keydown', handleKeyDown);
89+
}, [onDismiss, onSelect, quickLabels]);
90+
91+
// Click outside to dismiss
92+
useEffect(() => {
93+
const handlePointerDown = (e: PointerEvent) => {
94+
if (ref.current && !ref.current.contains(e.target as Node)) {
95+
onDismiss();
96+
}
97+
};
98+
// Defer to avoid catching the triggering click
99+
const timer = setTimeout(() => {
100+
document.addEventListener('pointerdown', handlePointerDown, true);
101+
}, 0);
102+
return () => {
103+
clearTimeout(timer);
104+
document.removeEventListener('pointerdown', handlePointerDown, true);
105+
};
106+
}, [onDismiss]);
107+
108+
if (!position) return null;
109+
110+
const animName = position.flipAbove ? 'qlp-in-above' : 'qlp-in-below';
111+
112+
return createPortal(
113+
<div
114+
ref={ref}
115+
data-quick-label-picker
116+
className="fixed z-[100]"
117+
style={{
118+
top: position.top,
119+
left: position.left,
120+
width: PICKER_WIDTH,
121+
...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
122+
animation: `${animName} 0.12s ease-out`,
123+
}}
124+
onMouseDown={(e) => e.stopPropagation()}
125+
>
126+
<style>{`
127+
@keyframes qlp-in-below {
128+
from { opacity: 0; transform: translateY(-4px); }
129+
to { opacity: 1; transform: translateY(0); }
130+
}
131+
@keyframes qlp-in-above {
132+
from { opacity: 0; transform: translateY(-100%) translateY(4px); }
133+
to { opacity: 1; transform: translateY(-100%); }
134+
}
135+
`}</style>
136+
137+
<div className="bg-popover border border-border/60 rounded-lg shadow-xl overflow-hidden">
138+
<QuickLabelDropdown labels={quickLabels} onSelect={onSelect} animate />
139+
</div>
140+
</div>,
141+
document.body
142+
);
143+
};

packages/ui/components/KeyboardShortcuts.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ const planShortcuts: ShortcutSection[] = [
9494
title: 'Annotations',
9595
shortcuts: [
9696
{ keys: ['a-z'], desc: 'Start typing comment', hint: 'When the annotation toolbar is open, any letter key opens the comment editor with that character' },
97-
{ keys: [alt, '1-8'], desc: 'Apply quick label', hint: 'When the toolbar is open, instantly applies the Nth preset label as an annotation' },
97+
{ keys: [alt, '1-0'], desc: 'Apply quick label', hint: 'Instantly applies the Nth preset label (0 = 10th). When the label picker is open, bare digits also work.' },
9898
{ keys: [mod, enter], desc: 'Submit comment' },
9999
{ keys: [mod, 'C'], desc: 'Copy selected text' },
100100
{ keys: ['Esc'], desc: 'Close toolbar / Cancel' },

0 commit comments

Comments
 (0)