Skip to content

Commit c007662

Browse files
grubmanItayclaudebacknotprop
authored
feat: add quick annotation labels for one-click preset feedback (#268)
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> Co-authored-by: Michael Ramos <mdramos8@gmail.com>
1 parent 5437a37 commit c007662

8 files changed

Lines changed: 370 additions & 25 deletions

File tree

packages/ui/components/AnnotationToolbar.tsx

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import React, { useState, useEffect, useRef } from "react";
1+
import React, { useState, useEffect, useRef, useMemo } 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";
56

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

@@ -19,6 +20,8 @@ interface AnnotationToolbarProps {
1920
onClose: () => void;
2021
/** Called when user wants to write a comment (opens CommentPopover in parent) */
2122
onRequestComment?: (initialChar?: string) => void;
23+
/** Called when a quick label chip is selected */
24+
onQuickLabel?: (label: QuickLabel) => void;
2225
/** Text to copy (for text selection, pass source.text) */
2326
copyText?: string;
2427
/** Close toolbar when element scrolls out of viewport */
@@ -36,6 +39,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
3639
onAnnotate,
3740
onClose,
3841
onRequestComment,
42+
onQuickLabel,
3943
copyText,
4044
closeOnScrollOut = false,
4145
isExiting = false,
@@ -44,7 +48,9 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
4448
}) => {
4549
const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
4650
const [copied, setCopied] = useState(false);
51+
const [showQuickLabels, setShowQuickLabels] = useState(false);
4752
const toolbarRef = useRef<HTMLDivElement>(null);
53+
const quickLabels = useMemo(() => getQuickLabels(), []);
4854

4955
const handleCopy = async () => {
5056
let textToCopy = copyText;
@@ -95,12 +101,27 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
95101
};
96102
}, [element, positionMode, closeOnScrollOut, onClose]);
97103

98-
// Type-to-comment: typing opens CommentPopover via parent
104+
// Type-to-comment + Alt+N quick label shortcuts
99105
useEffect(() => {
100106
const handleKeyDown = (e: KeyboardEvent) => {
101107
if (e.isComposing) return;
102108
if (isEditableElement(e.target) || isEditableElement(document.activeElement)) return;
103-
if (e.key === "Escape") { onClose(); return; }
109+
if (e.key === "Escape") {
110+
setShowQuickLabels(false);
111+
onClose();
112+
return;
113+
}
114+
115+
// Alt+1..8: apply quick label
116+
if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit8') {
117+
e.preventDefault();
118+
const index = parseInt(e.code.slice(5), 10) - 1;
119+
if (index < quickLabels.length) {
120+
onQuickLabel?.(quickLabels[index]);
121+
}
122+
return;
123+
}
124+
104125
if (e.ctrlKey || e.metaKey || e.altKey) return;
105126
if (e.key === "Tab" || e.key === "Enter") return;
106127
if (e.key.length !== 1) return;
@@ -110,7 +131,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
110131

111132
window.addEventListener("keydown", handleKeyDown);
112133
return () => window.removeEventListener("keydown", handleKeyDown);
113-
}, [onClose, onRequestComment]);
134+
}, [onClose, onRequestComment, onQuickLabel, quickLabels]);
114135

115136
useDismissOnOutsideAndEscape({
116137
enabled: true,
@@ -180,6 +201,25 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
180201
label="Comment"
181202
className="text-accent hover:bg-accent/10"
182203
/>
204+
{onQuickLabel && (
205+
<div className="relative">
206+
<ToolbarButton
207+
onClick={() => setShowQuickLabels(prev => !prev)}
208+
icon={<ZapIcon />}
209+
label="Quick label"
210+
className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"}
211+
/>
212+
{showQuickLabels && (
213+
<QuickLabelDropdown
214+
labels={quickLabels}
215+
onSelect={(label) => {
216+
setShowQuickLabels(false);
217+
onQuickLabel(label);
218+
}}
219+
/>
220+
)}
221+
</div>
222+
)}
183223
<div className="w-px h-5 bg-border mx-0.5" />
184224
<ToolbarButton
185225
onClick={onClose}
@@ -193,6 +233,45 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
193233
);
194234
};
195235

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+
196275
// Icons
197276
const CopyIcon = () => (
198277
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -218,6 +297,12 @@ const CommentIcon = () => (
218297
</svg>
219298
);
220299

300+
const ZapIcon = () => (
301+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
302+
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
303+
</svg>
304+
);
305+
221306
const CloseIcon = () => (
222307
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
223308
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />

packages/ui/components/KeyboardShortcuts.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +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' },
9798
{ keys: [mod, enter], desc: 'Submit comment' },
9899
{ keys: [mod, 'C'], desc: 'Copy selected text' },
99100
{ keys: ['Esc'], desc: 'Close toolbar / Cancel' },

packages/ui/components/Settings.tsx

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ import {
4646
} from '../utils/defaultNotesApp';
4747
import { useAgents } from '../hooks/useAgents';
4848
import { KeyboardShortcuts } from './KeyboardShortcuts';
49+
import { type QuickLabel, getQuickLabels, saveQuickLabels, resetQuickLabels, DEFAULT_QUICK_LABELS, getLabelColors, LABEL_COLOR_MAP } from '../utils/quickLabels';
4950

50-
type SettingsTab = 'general' | 'display' | 'saving' | 'shortcuts' | 'obsidian' | 'bear';
51+
type SettingsTab = 'general' | 'display' | 'saving' | 'labels' | 'shortcuts' | 'obsidian' | 'bear';
5152

5253
interface SettingsProps {
5354
taterMode: boolean;
@@ -81,6 +82,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
8182
const [agentWarning, setAgentWarning] = useState<string | null>(null);
8283
const [autoCloseDelay, setAutoCloseDelayState] = useState<AutoCloseDelay>('off');
8384
const [defaultNotesApp, setDefaultNotesApp] = useState<DefaultNotesApp>('ask');
85+
const [quickLabelsState, setQuickLabelsState] = useState<QuickLabel[]>([]);
8486

8587
// Fetch available agents for OpenCode
8688
const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin ?? null);
@@ -90,6 +92,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
9092
if (mode === 'plan') {
9193
t.push({ id: 'display', label: 'Display' });
9294
t.push({ id: 'saving', label: 'Saving' });
95+
t.push({ id: 'labels', label: 'Labels' });
9396
}
9497
t.push({ id: 'shortcuts', label: 'Shortcuts' });
9598
return t;
@@ -118,6 +121,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
118121
setPermissionMode(getPermissionModeSettings().mode);
119122
setAutoCloseDelayState(getAutoCloseDelay());
120123
setDefaultNotesApp(getDefaultNotesApp());
124+
setQuickLabelsState(getQuickLabels());
121125

122126
// Validate agent setting when dialog opens
123127
if (origin === 'opencode') {
@@ -751,6 +755,108 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
751755
</>
752756
)}
753757

758+
{/* === LABELS TAB === */}
759+
{activeTab === 'labels' && (
760+
<>
761+
<div className="flex items-center justify-between">
762+
<div>
763+
<div className="text-sm font-medium">Quick Labels</div>
764+
<div className="text-xs text-muted-foreground">
765+
Preset annotations for one-click feedback
766+
</div>
767+
</div>
768+
<button
769+
onClick={() => {
770+
resetQuickLabels();
771+
setQuickLabelsState(DEFAULT_QUICK_LABELS);
772+
}}
773+
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
774+
>
775+
Reset to defaults
776+
</button>
777+
</div>
778+
779+
<div className="space-y-1.5">
780+
{quickLabelsState.map((label, index) => {
781+
const colors = getLabelColors(label.color);
782+
return (
783+
<div key={index} className="flex items-center gap-2 p-2 rounded-lg" style={{ backgroundColor: colors.bg }}>
784+
<span className="text-sm flex-shrink-0">{label.emoji}</span>
785+
<input
786+
type="text"
787+
value={label.text}
788+
onChange={(e) => {
789+
const updated = [...quickLabelsState];
790+
updated[index] = {
791+
...label,
792+
text: e.target.value,
793+
id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
794+
};
795+
setQuickLabelsState(updated);
796+
saveQuickLabels(updated);
797+
}}
798+
className="flex-1 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
799+
/>
800+
<select
801+
value={label.color}
802+
onChange={(e) => {
803+
const updated = [...quickLabelsState];
804+
updated[index] = { ...label, color: e.target.value };
805+
setQuickLabelsState(updated);
806+
saveQuickLabels(updated);
807+
}}
808+
className="px-1.5 py-1 bg-background/80 rounded text-[10px] focus:outline-none focus:ring-1 focus:ring-primary/50"
809+
>
810+
{Object.keys(LABEL_COLOR_MAP).map(c => (
811+
<option key={c} value={c}>{c}</option>
812+
))}
813+
</select>
814+
<span className="text-[10px] text-muted-foreground/50 font-mono w-8 text-center flex-shrink-0">
815+
{index < 8 ? `${navigator.platform?.includes('Mac') ? '⌥' : 'Alt+'}${index + 1}` : ''}
816+
</span>
817+
<button
818+
onClick={() => {
819+
const updated = quickLabelsState.filter((_, i) => i !== index);
820+
setQuickLabelsState(updated);
821+
saveQuickLabels(updated);
822+
}}
823+
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
824+
title="Remove label"
825+
>
826+
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
827+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
828+
</svg>
829+
</button>
830+
</div>
831+
);
832+
})}
833+
</div>
834+
835+
{quickLabelsState.length < 12 && (
836+
<button
837+
onClick={() => {
838+
const newLabel: QuickLabel = {
839+
id: `custom-${Date.now()}`,
840+
emoji: '📌',
841+
text: 'New label',
842+
color: 'blue',
843+
};
844+
const updated = [...quickLabelsState, newLabel];
845+
setQuickLabelsState(updated);
846+
saveQuickLabels(updated);
847+
}}
848+
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"
849+
>
850+
+ Add label
851+
</button>
852+
)}
853+
854+
<div className="text-[10px] text-muted-foreground/70">
855+
Use {navigator.platform?.includes('Mac') ? '⌥' : 'Alt+'}1 through {navigator.platform?.includes('Mac') ? '⌥' : 'Alt+'}8 when the annotation toolbar is visible to apply a label instantly.
856+
</div>
857+
</>
858+
)}
859+
754860
{/* === SHORTCUTS TAB === */}
755861
{activeTab === 'shortcuts' && (
756862
<KeyboardShortcuts mode={mode} />

0 commit comments

Comments
 (0)