From 8740b2aa5df543666eef2147fd52d27edbeae57d Mon Sep 17 00:00:00 2001 From: Itay Grubman Date: Tue, 10 Mar 2026 13:34:29 +0200 Subject: [PATCH 1/5] feat: add quick annotation labels for one-click preset feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bun.lock | 7 +- packages/ui/components/AnnotationToolbar.tsx | 93 +++++++++++++++- packages/ui/components/KeyboardShortcuts.tsx | 1 + packages/ui/components/Settings.tsx | 108 ++++++++++++++++++- packages/ui/components/Viewer.tsx | 106 ++++++++++++++++-- packages/ui/types.ts | 1 + packages/ui/utils/parser.ts | 23 +++- packages/ui/utils/quickLabels.ts | 72 +++++++++++++ packages/ui/utils/sharing.ts | 18 ++-- 9 files changed, 402 insertions(+), 27 deletions(-) create mode 100644 packages/ui/utils/quickLabels.ts diff --git a/bun.lock b/bun.lock index a5be0522..8a387084 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "plannotator", @@ -53,7 +54,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.11.2", + "version": "0.11.3", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -74,7 +75,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.11.2", + "version": "0.11.3", "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.53.0", }, @@ -154,7 +155,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.11.2", + "version": "0.11.3", "dependencies": { "@plannotator/shared": "workspace:*", }, diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index 7fbbb675..9fb2f1dc 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -1,7 +1,8 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useMemo } from "react"; import { AnnotationType } from "../types"; import { createPortal } from "react-dom"; import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape"; +import { type QuickLabel, getQuickLabels, getLabelColors } from "../utils/quickLabels"; type PositionMode = 'center-above' | 'top-right'; @@ -19,6 +20,8 @@ interface AnnotationToolbarProps { onClose: () => void; /** Called when user wants to write a comment (opens CommentPopover in parent) */ onRequestComment?: (initialChar?: string) => void; + /** Called when a quick label chip is selected */ + onQuickLabel?: (label: QuickLabel) => void; /** Text to copy (for text selection, pass source.text) */ copyText?: string; /** Close toolbar when element scrolls out of viewport */ @@ -36,6 +39,7 @@ export const AnnotationToolbar: React.FC = ({ onAnnotate, onClose, onRequestComment, + onQuickLabel, copyText, closeOnScrollOut = false, isExiting = false, @@ -44,7 +48,9 @@ export const AnnotationToolbar: React.FC = ({ }) => { const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null); const [copied, setCopied] = useState(false); + const [showQuickLabels, setShowQuickLabels] = useState(false); const toolbarRef = useRef(null); + const quickLabels = useMemo(() => getQuickLabels(), []); const handleCopy = async () => { let textToCopy = copyText; @@ -95,12 +101,27 @@ export const AnnotationToolbar: React.FC = ({ }; }, [element, positionMode, closeOnScrollOut, onClose]); - // Type-to-comment: typing opens CommentPopover via parent + // Type-to-comment + Alt+N quick label shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.isComposing) return; if (isEditableElement(e.target) || isEditableElement(document.activeElement)) return; - if (e.key === "Escape") { onClose(); return; } + if (e.key === "Escape") { + setShowQuickLabels(false); + onClose(); + return; + } + + // Alt+1..8: apply quick label + if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit8') { + e.preventDefault(); + const index = parseInt(e.code.slice(5), 10) - 1; + if (index < quickLabels.length) { + onQuickLabel?.(quickLabels[index]); + } + return; + } + if (e.ctrlKey || e.metaKey || e.altKey) return; if (e.key === "Tab" || e.key === "Enter") return; if (e.key.length !== 1) return; @@ -110,7 +131,7 @@ export const AnnotationToolbar: React.FC = ({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [onClose, onRequestComment]); + }, [onClose, onRequestComment, onQuickLabel, quickLabels]); useDismissOnOutsideAndEscape({ enabled: true, @@ -180,6 +201,25 @@ export const AnnotationToolbar: React.FC = ({ label="Comment" className="text-accent hover:bg-accent/10" /> + {onQuickLabel && ( +
+ setShowQuickLabels(prev => !prev)} + icon={} + label="Quick label" + className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"} + /> + {showQuickLabels && ( + { + setShowQuickLabels(false); + onQuickLabel(label); + }} + /> + )} +
+ )}
= ({ ); }; +// Quick Label Dropdown +const QuickLabelDropdown: React.FC<{ + labels: QuickLabel[]; + onSelect: (label: QuickLabel) => void; +}> = ({ labels, onSelect }) => { + const isMac = navigator.platform?.includes('Mac'); + const altKey = isMac ? 'βŒ₯' : 'Alt+'; + + return ( +
e.stopPropagation()} + > +
Quick Labels
+
+ {labels.map((label, index) => { + const colors = getLabelColors(label.color); + return ( + + ); + })} +
+
+ ); +}; + // Icons const CopyIcon = () => ( @@ -218,6 +297,12 @@ const CommentIcon = () => ( ); +const ZapIcon = () => ( + + + +); + const CloseIcon = () => ( diff --git a/packages/ui/components/KeyboardShortcuts.tsx b/packages/ui/components/KeyboardShortcuts.tsx index c5c8e5d8..713ed278 100644 --- a/packages/ui/components/KeyboardShortcuts.tsx +++ b/packages/ui/components/KeyboardShortcuts.tsx @@ -94,6 +94,7 @@ const planShortcuts: ShortcutSection[] = [ title: 'Annotations', shortcuts: [ { keys: ['a-z'], desc: 'Start typing comment', hint: 'When the annotation toolbar is open, any letter key opens the comment editor with that character' }, + { keys: [alt, '1-8'], desc: 'Apply quick label', hint: 'When the toolbar is open, instantly applies the Nth preset label as an annotation' }, { keys: [mod, enter], desc: 'Submit comment' }, { keys: [mod, 'C'], desc: 'Copy selected text' }, { keys: ['Esc'], desc: 'Close toolbar / Cancel' }, diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 8c91e541..9c51c3a6 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -44,8 +44,9 @@ import { } from '../utils/defaultNotesApp'; import { useAgents } from '../hooks/useAgents'; import { KeyboardShortcuts } from './KeyboardShortcuts'; +import { type QuickLabel, getQuickLabels, saveQuickLabels, resetQuickLabels, DEFAULT_QUICK_LABELS, getLabelColors, LABEL_COLOR_MAP } from '../utils/quickLabels'; -type SettingsTab = 'general' | 'display' | 'saving' | 'shortcuts' | 'obsidian' | 'bear'; +type SettingsTab = 'general' | 'display' | 'saving' | 'labels' | 'shortcuts' | 'obsidian' | 'bear'; interface SettingsProps { taterMode: boolean; @@ -76,6 +77,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const [agentWarning, setAgentWarning] = useState(null); const [autoCloseDelay, setAutoCloseDelayState] = useState('off'); const [defaultNotesApp, setDefaultNotesApp] = useState('ask'); + const [quickLabelsState, setQuickLabelsState] = useState([]); // Fetch available agents for OpenCode const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin ?? null); @@ -85,6 +87,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange if (mode === 'plan') { t.push({ id: 'display', label: 'Display' }); t.push({ id: 'saving', label: 'Saving' }); + t.push({ id: 'labels', label: 'Labels' }); } t.push({ id: 'shortcuts', label: 'Shortcuts' }); return t; @@ -105,6 +108,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange setPermissionMode(getPermissionModeSettings().mode); setAutoCloseDelayState(getAutoCloseDelay()); setDefaultNotesApp(getDefaultNotesApp()); + setQuickLabelsState(getQuickLabels()); // Validate agent setting when dialog opens if (origin === 'opencode') { @@ -616,6 +620,108 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange )} + {/* === LABELS TAB === */} + {activeTab === 'labels' && ( + <> +
+
+
Quick Labels
+
+ Preset annotations for one-click feedback +
+
+ +
+ +
+ {quickLabelsState.map((label, index) => { + const colors = getLabelColors(label.color); + return ( +
+ {label.emoji} + { + const updated = [...quickLabelsState]; + updated[index] = { + ...label, + text: e.target.value, + id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''), + }; + setQuickLabelsState(updated); + saveQuickLabels(updated); + }} + className="flex-1 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + + {index < 8 ? `${navigator.platform?.includes('Mac') ? 'βŒ₯' : 'Alt+'}${index + 1}` : ''} + + +
+ ); + })} +
+ + {quickLabelsState.length < 12 && ( + + )} + +
+ 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. +
+ + )} + {/* === SHORTCUTS TAB === */} {activeTab === 'shortcuts' && ( diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 1de281c8..4987f422 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -5,11 +5,30 @@ import 'highlight.js/styles/github-dark.css'; import { Block, Annotation, AnnotationType, EditorMode, type InputMethod, type ImageAttachment } from '../types'; import { Frontmatter } from '../utils/parser'; import { AnnotationToolbar } from './AnnotationToolbar'; + +// Debug error boundary to catch silent toolbar crashes +class ToolbarErrorBoundary extends React.Component< + { children: React.ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { return { error }; } + componentDidCatch(error: Error) { console.error('AnnotationToolbar crashed:', error); } + render() { + if (this.state.error) { + return
+ Toolbar error: {this.state.error.message} +
; + } + return this.props.children; + } +} import { CommentPopover } from './CommentPopover'; import { TaterSpriteSitting } from './TaterSpriteSitting'; import { AttachmentsButton } from './AttachmentsButton'; import { MermaidBlock } from './MermaidBlock'; import { getIdentity } from '../utils/identity'; +import { type QuickLabel } from '../utils/quickLabels'; import { PlanDiffBadge } from './plan-diff/PlanDiffBadge'; import { PinpointOverlay } from './PinpointOverlay'; import { usePinpoint } from '../hooks/usePinpoint'; @@ -220,7 +239,8 @@ export const Viewer = forwardRef(({ source: any, type: AnnotationType, text?: string, - images?: ImageAttachment[] + images?: ImageAttachment[], + isQuickLabel?: boolean, ) => { const doms = highlighter.getDoms(source.id); let blockId = ''; @@ -253,6 +273,7 @@ export const Viewer = forwardRef(({ startMeta: source.startMeta, endMeta: source.endMeta, images, + ...(isQuickLabel ? { isQuickLabel: true } : {}), }; if (type === AnnotationType.DELETION) { @@ -552,7 +573,38 @@ export const Viewer = forwardRef(({ highlighter.run(); - return () => highlighter.dispose(); + // Mobile: bridge native text selection (long-press) to the highlighter's CREATE flow. + // On mobile/touch, native selection handles don't reliably fire touchend on the content + // root, so the web-highlighter's built-in PointerEnd listener never triggers. + // This selectionchange listener detects valid selections and uses the highlighter's + // public fromRange() API to programmatically create the highlight and emit CREATE. + // Use (pointer: coarse) instead of 'ontouchstart' in window β€” the latter is true on + // desktop Chrome when the machine has a touchscreen or DevTools touch was toggled. + const isTouchPrimary = window.matchMedia('(pointer: coarse)').matches; + let selectionTimer: ReturnType; + const handleSelectionChange = isTouchPrimary ? () => { + clearTimeout(selectionTimer); + selectionTimer = setTimeout(() => { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) return; + if (!containerRef.current?.contains(sel.anchorNode)) return; + + const range = sel.getRangeAt(0); + highlighter.fromRange(range); + }, 400); + } : null; + + if (handleSelectionChange) { + document.addEventListener('selectionchange', handleSelectionChange); + } + + return () => { + if (handleSelectionChange) { + clearTimeout(selectionTimer); + document.removeEventListener('selectionchange', handleSelectionChange); + } + highlighter.dispose(); + }; }, [onSelectAnnotation]); @@ -630,6 +682,19 @@ export const Viewer = forwardRef(({ window.getSelection()?.removeAllRanges(); }; + const handleQuickLabel = (label: QuickLabel) => { + const highlighter = highlighterRef.current; + if (!toolbarState || !highlighter) return; + + createAnnotationFromSource( + highlighter, toolbarState.source, AnnotationType.COMMENT, + `${label.emoji} ${label.text}`, undefined, true + ); + pendingSourceRef.current = null; + setToolbarState(null); + window.getSelection()?.removeAllRanges(); + }; + const handleToolbarClose = () => { if (toolbarState && highlighterRef.current) { highlighterRef.current.remove(toolbarState.source.id); @@ -645,6 +710,7 @@ export const Viewer = forwardRef(({ type: AnnotationType, text?: string, images?: ImageAttachment[], + isQuickLabel?: boolean, ) => { const id = `codeblock-${Date.now()}`; const codeText = codeEl.textContent || ''; @@ -668,6 +734,7 @@ export const Viewer = forwardRef(({ createdA: Date.now(), author: getIdentity(), images, + ...(isQuickLabel ? { isQuickLabel: true } : {}), }; justCreatedIdRef.current = newAnnotation.id; @@ -683,6 +750,17 @@ export const Viewer = forwardRef(({ setHoveredCodeBlock(null); }; + const handleCodeBlockQuickLabel = (label: QuickLabel) => { + if (!hoveredCodeBlock) return; + const codeEl = hoveredCodeBlock.element.querySelector('code'); + if (!codeEl) return; + applyCodeBlockAnnotation( + hoveredCodeBlock.block.id, codeEl, AnnotationType.COMMENT, + `${label.emoji} ${label.text}`, undefined, true + ); + setHoveredCodeBlock(null); + }; + const handleCodeBlockToolbarClose = () => { setHoveredCodeBlock(null); }; @@ -932,25 +1010,30 @@ export const Viewer = forwardRef(({ {/* Text selection toolbar */} {toolbarState && ( - + + + )} {/* Code block hover toolbar */} {hoveredCodeBlock && !toolbarState && ( + { if (hoverTimeoutRef.current) { @@ -969,6 +1052,7 @@ export const Viewer = forwardRef(({ }, 100); }} /> + )} {/* Pinpoint hover overlay */} diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 1329efec..4b71fde4 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -26,6 +26,7 @@ export interface Annotation { createdA: number; author?: string; // Tater identity for collaborative sharing images?: ImageAttachment[]; // Attached images with human-readable names + isQuickLabel?: boolean; // true if created via quick label chip // web-highlighter metadata for cross-element selections startMeta?: { parentTagName: string; diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index cc77b50a..14fe91ff 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -296,8 +296,12 @@ export const exportAnnotations = (blocks: Block[], annotations: any[], globalAtt break; case 'COMMENT': - output += `Feedback on: "${ann.originalText}"\n`; - output += `> ${ann.text}\n`; + if (ann.isQuickLabel) { + output += `[${ann.text}] Feedback on: "${ann.originalText}"\n`; + } else { + output += `Feedback on: "${ann.originalText}"\n`; + output += `> ${ann.text}\n`; + } break; case 'GLOBAL_COMMENT': @@ -319,6 +323,21 @@ export const exportAnnotations = (blocks: Block[], annotations: any[], globalAtt output += `---\n`; + // Quick Label Summary + const labeledAnns = sortedAnns.filter((a: any) => a.isQuickLabel && a.text); + if (labeledAnns.length > 0) { + const grouped = new Map(); + labeledAnns.forEach((a: any) => { + grouped.set(a.text, (grouped.get(a.text) || 0) + 1); + }); + + output += `\n## Label Summary\n\n`; + for (const [text, count] of grouped) { + output += `- **${text}**: ${count}\n`; + } + output += '\n'; + } + return output; }; diff --git a/packages/ui/utils/quickLabels.ts b/packages/ui/utils/quickLabels.ts new file mode 100644 index 00000000..b231d6ee --- /dev/null +++ b/packages/ui/utils/quickLabels.ts @@ -0,0 +1,72 @@ +/** + * Quick Labels β€” preset annotation labels for one-click feedback + * + * Labels are stored in cookies (same pattern as other settings) + * so they persist across different port-based sessions. + */ + +import { storage } from './storage'; + +const STORAGE_KEY = 'plannotator-quick-labels'; + +export interface QuickLabel { + id: string; // kebab-case identifier e.g. "needs-tests" + emoji: string; // single emoji e.g. "πŸ§ͺ" + text: string; // display text e.g. "Needs tests" + color: string; // key into LABEL_COLOR_MAP +} + +/** Inline styles for label colors (avoids Tailwind dynamic class purging) */ +export const LABEL_COLOR_MAP: Record = { + blue: { bg: 'rgba(59,130,246,0.15)', text: '#2563eb', darkText: '#60a5fa' }, + red: { bg: 'rgba(239,68,68,0.15)', text: '#dc2626', darkText: '#f87171' }, + orange: { bg: 'rgba(249,115,22,0.15)', text: '#ea580c', darkText: '#fb923c' }, + yellow: { bg: 'rgba(234,179,8,0.15)', text: '#ca8a04', darkText: '#facc15' }, + purple: { bg: 'rgba(147,51,234,0.15)', text: '#9333ea', darkText: '#a78bfa' }, + teal: { bg: 'rgba(20,184,166,0.15)', text: '#0d9488', darkText: '#2dd4bf' }, + pink: { bg: 'rgba(236,72,153,0.15)', text: '#db2777', darkText: '#f472b6' }, + green: { bg: 'rgba(34,197,94,0.15)', text: '#16a34a', darkText: '#4ade80' }, +}; + +export const DEFAULT_QUICK_LABELS: QuickLabel[] = [ + { id: 'needs-tests', emoji: 'πŸ§ͺ', text: 'Needs tests', color: 'blue' }, + { id: 'security-concern', emoji: 'πŸ”’', text: 'Security concern', color: 'red' }, + { id: 'break-this-up', emoji: 'βœ‚οΈ', text: 'Break this up', color: 'orange' }, + { id: 'clarify-this-step', emoji: '❓', text: 'Clarify this step', color: 'yellow' }, + { id: 'wrong-order', emoji: 'πŸ”€', text: 'Wrong order', color: 'purple' }, + { id: 'consider-edge-cases', emoji: '🧩', text: 'Consider edge cases', color: 'teal' }, + { id: 'discuss-first', emoji: 'πŸ’¬', text: 'Discuss first', color: 'pink' }, + { id: 'nice-approach', emoji: 'πŸ‘', text: 'Nice approach', color: 'green' }, +]; + +export function getQuickLabels(): QuickLabel[] { + const raw = storage.getItem(STORAGE_KEY); + if (!raw) return DEFAULT_QUICK_LABELS; + try { + const parsed = JSON.parse(raw) as QuickLabel[]; + return parsed.length > 0 ? parsed : DEFAULT_QUICK_LABELS; + } catch { + return DEFAULT_QUICK_LABELS; + } +} + +export function saveQuickLabels(labels: QuickLabel[]): void { + storage.setItem(STORAGE_KEY, JSON.stringify(labels)); +} + +export function resetQuickLabels(): void { + storage.removeItem(STORAGE_KEY); +} + +/** Find a configured label whose "emoji text" matches an annotation's text field */ +export function findLabelByText(annotationText: string): QuickLabel | undefined { + return getQuickLabels().find(l => `${l.emoji} ${l.text}` === annotationText); +} + +/** Get color styles for a label, respecting dark mode */ +export function getLabelColors(color: string): { bg: string; text: string } { + const colors = LABEL_COLOR_MAP[color]; + if (!colors) return { bg: 'rgba(128,128,128,0.15)', text: '#666' }; + const isDark = document.documentElement.classList.contains('dark'); + return { bg: colors.bg, text: isDark ? colors.darkText : colors.text }; +} diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts index 9799ffd1..bd7715f1 100644 --- a/packages/ui/utils/sharing.ts +++ b/packages/ui/utils/sharing.ts @@ -15,13 +15,13 @@ import { encrypt, decrypt } from '@plannotator/shared/crypto'; // Image in shareable format: plain string (old) or [path, name] tuple (new) type ShareableImage = string | [string, string]; -// Minimal shareable annotation format: [type, originalText, text?, author?, images?] +// Minimal shareable annotation format: [type, originalText, text?, author?, images?, quickLabel?] export type ShareableAnnotation = - | ['D', string, string | null, ShareableImage[]?] // Deletion: type, original, author, images - | ['R', string, string, string | null, ShareableImage[]?] // Replacement: type, original, replacement, author, images - | ['C', string, string, string | null, ShareableImage[]?] // Comment: type, original, comment, author, images - | ['I', string, string, string | null, ShareableImage[]?] // Insertion: type, context, new text, author, images - | ['G', string, string | null, ShareableImage[]?]; // Global Comment: type, comment, author, images + | ['D', string, string | null, ShareableImage[]?] // Deletion: type, original, author, images + | ['R', string, string, string | null, ShareableImage[]?] // Replacement: type, original, replacement, author, images + | ['C', string, string, string | null, ShareableImage[]?, (1)?] // Comment: type, original, comment, author, images, isQuickLabel + | ['I', string, string, string | null, ShareableImage[]?] // Insertion: type, context, new text, author, images + | ['G', string, string | null, ShareableImage[]?]; // Global Comment: type, comment, author, images export interface SharePayload { p: string; // plan markdown @@ -75,6 +75,9 @@ export function toShareable(annotations: Annotation[]): ShareableAnnotation[] { } // R, C, I all have text + if (type === 'C' && ann.isQuickLabel) { + return ['C', ann.originalText, ann.text || '', author, images ?? undefined, 1] as ShareableAnnotation; + } return [type, ann.originalText, ann.text || '', author, images] as ShareableAnnotation; }); } @@ -122,6 +125,8 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { const text = type === 'D' ? undefined : item[2] as string; const author = type === 'D' ? item[2] as string | null : item[3] as string | null; const rawImages = type === 'D' ? item[3] as ShareableImage[] | undefined : item[4] as ShareableImage[] | undefined; + // Comment annotations may have isQuickLabel flag at index 5 + const isQuickLabel = type === 'C' && item.length > 5 && item[5] === 1 ? true : undefined; return { id: `shared-${index}-${Date.now()}`, @@ -134,6 +139,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { createdA: Date.now() + index, // Preserve order author: author || undefined, images: parseShareableImages(rawImages), + ...(isQuickLabel ? { isQuickLabel } : {}), // startMeta/endMeta will be set by web-highlighter }; }); From 4fbf11c07c13b67401394dffb4be363974b3125c Mon Sep 17 00:00:00 2001 From: Itay Grubman Date: Tue, 10 Mar 2026 22:55:32 +0200 Subject: [PATCH 2/5] feat: add quick label selection mode for one-click annotations --- packages/ui/components/AnnotationToolbar.tsx | 51 ++------ .../ui/components/AnnotationToolstrip.tsx | 17 +++ .../components/FloatingQuickLabelPicker.tsx | 113 ++++++++++++++++++ packages/ui/components/KeyboardShortcuts.tsx | 2 +- packages/ui/components/QuickLabelDropdown.tsx | 36 ++++++ packages/ui/components/Viewer.tsx | 62 +++++++++- packages/ui/types.ts | 2 +- packages/ui/utils/editorMode.ts | 2 +- 8 files changed, 243 insertions(+), 42 deletions(-) create mode 100644 packages/ui/components/FloatingQuickLabelPicker.tsx create mode 100644 packages/ui/components/QuickLabelDropdown.tsx diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index 9fb2f1dc..59176223 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -2,7 +2,8 @@ import React, { useState, useEffect, useRef, useMemo } from "react"; import { AnnotationType } from "../types"; import { createPortal } from "react-dom"; import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape"; -import { type QuickLabel, getQuickLabels, getLabelColors } from "../utils/quickLabels"; +import { type QuickLabel, getQuickLabels } from "../utils/quickLabels"; +import { QuickLabelDropdown } from "./QuickLabelDropdown"; type PositionMode = 'center-above' | 'top-right'; @@ -210,7 +211,7 @@ export const AnnotationToolbar: React.FC = ({ className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"} /> {showQuickLabels && ( - { setShowQuickLabels(false); @@ -233,44 +234,18 @@ export const AnnotationToolbar: React.FC = ({ ); }; -// Quick Label Dropdown -const QuickLabelDropdown: React.FC<{ +// Inline dropdown wrapper positioned below the zap button +const InlineQuickLabelDropdown: React.FC<{ labels: QuickLabel[]; onSelect: (label: QuickLabel) => void; -}> = ({ labels, onSelect }) => { - const isMac = navigator.platform?.includes('Mac'); - const altKey = isMac ? 'βŒ₯' : 'Alt+'; - - return ( -
e.stopPropagation()} - > -
Quick Labels
-
- {labels.map((label, index) => { - const colors = getLabelColors(label.color); - return ( - - ); - })} -
-
- ); -}; +}> = ({ labels, onSelect }) => ( +
+ +
+); // Icons const CopyIcon = () => ( diff --git a/packages/ui/components/AnnotationToolstrip.tsx b/packages/ui/components/AnnotationToolstrip.tsx index 78490fcb..c3808c96 100644 --- a/packages/ui/components/AnnotationToolstrip.tsx +++ b/packages/ui/components/AnnotationToolstrip.tsx @@ -104,6 +104,18 @@ export const AnnotationToolstrip: React.FC = ({ } /> + onModeChange('quickLabel')} + label="Label" + color="warning" + mounted={mounted} + icon={ + + + + } + />
{/* Help */} @@ -203,6 +215,11 @@ const colorStyles = { hover: 'text-destructive/80 bg-destructive/8', inactive: 'text-muted-foreground hover:text-foreground', }, + warning: { + active: 'bg-background text-foreground shadow-sm', + hover: 'text-amber-500/80 bg-amber-500/8', + inactive: 'text-muted-foreground hover:text-foreground', + }, } as const; type ButtonColor = keyof typeof colorStyles; diff --git a/packages/ui/components/FloatingQuickLabelPicker.tsx b/packages/ui/components/FloatingQuickLabelPicker.tsx new file mode 100644 index 00000000..1210f2c4 --- /dev/null +++ b/packages/ui/components/FloatingQuickLabelPicker.tsx @@ -0,0 +1,113 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { type QuickLabel, getQuickLabels } from '../utils/quickLabels'; +import { QuickLabelDropdown } from './QuickLabelDropdown'; + +interface FloatingQuickLabelPickerProps { + anchorEl: HTMLElement; + onSelect: (label: QuickLabel) => void; + onDismiss: () => void; +} + +export const FloatingQuickLabelPicker: React.FC = ({ + anchorEl, + onSelect, + onDismiss, +}) => { + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + const ref = useRef(null); + const quickLabels = useMemo(() => getQuickLabels(), []); + + // Position tracking + useEffect(() => { + const updatePosition = () => { + const rect = anchorEl.getBoundingClientRect(); + const dropdownHeight = 120; // approximate + const gap = 8; + + const hasSpaceAbove = rect.top > dropdownHeight + gap; + const top = hasSpaceAbove + ? rect.top - gap + : rect.bottom + gap; + + setPosition({ + top, + left: rect.left + rect.width / 2, + }); + }; + + updatePosition(); + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [anchorEl]); + + // Keyboard: Alt+1..8 and Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onDismiss(); + return; + } + if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit8') { + e.preventDefault(); + const index = parseInt(e.code.slice(5), 10) - 1; + if (index < quickLabels.length) { + onSelect(quickLabels[index]); + } + return; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onDismiss, onSelect, quickLabels]); + + // Click outside to dismiss + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + onDismiss(); + } + }; + + // Use setTimeout to avoid dismissing from the same click that triggered the picker + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClick); + }, 0); + + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleClick); + }; + }, [onDismiss]); + + if (!position) return null; + + return createPortal( +
e.stopPropagation()} + > + + +
, + document.body + ); +}; diff --git a/packages/ui/components/KeyboardShortcuts.tsx b/packages/ui/components/KeyboardShortcuts.tsx index 713ed278..986a40dc 100644 --- a/packages/ui/components/KeyboardShortcuts.tsx +++ b/packages/ui/components/KeyboardShortcuts.tsx @@ -94,7 +94,7 @@ const planShortcuts: ShortcutSection[] = [ title: 'Annotations', shortcuts: [ { keys: ['a-z'], desc: 'Start typing comment', hint: 'When the annotation toolbar is open, any letter key opens the comment editor with that character' }, - { keys: [alt, '1-8'], desc: 'Apply quick label', hint: 'When the toolbar is open, instantly applies the Nth preset label as an annotation' }, + { keys: [alt, '1-8'], desc: 'Apply quick label', hint: 'When the toolbar or label picker is open, instantly applies the Nth preset label' }, { keys: [mod, enter], desc: 'Submit comment' }, { keys: [mod, 'C'], desc: 'Copy selected text' }, { keys: ['Esc'], desc: 'Close toolbar / Cancel' }, diff --git a/packages/ui/components/QuickLabelDropdown.tsx b/packages/ui/components/QuickLabelDropdown.tsx new file mode 100644 index 00000000..d41d42bd --- /dev/null +++ b/packages/ui/components/QuickLabelDropdown.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { type QuickLabel, getLabelColors } from '../utils/quickLabels'; + +export const QuickLabelDropdown: React.FC<{ + labels: QuickLabel[]; + onSelect: (label: QuickLabel) => void; +}> = ({ labels, onSelect }) => { + const isMac = navigator.platform?.includes('Mac'); + const altKey = isMac ? 'βŒ₯' : 'Alt+'; + + return ( +
e.stopPropagation()}> +
Quick Labels
+
+ {labels.map((label, index) => { + const colors = getLabelColors(label.color); + return ( + + ); + })} +
+
+ ); +}; diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 4987f422..b32f1b6d 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -5,6 +5,7 @@ import 'highlight.js/styles/github-dark.css'; import { Block, Annotation, AnnotationType, EditorMode, type InputMethod, type ImageAttachment } from '../types'; import { Frontmatter } from '../utils/parser'; import { AnnotationToolbar } from './AnnotationToolbar'; +import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker'; // Debug error boundary to catch silent toolbar crashes class ToolbarErrorBoundary extends React.Component< @@ -156,6 +157,11 @@ export const Viewer = forwardRef(({ source?: any; codeBlock?: { block: Block; element: HTMLElement }; } | null>(null); + const [quickLabelPicker, setQuickLabelPicker] = useState<{ + anchorEl: HTMLElement; + source?: any; + codeBlock?: { block: Block; element: HTMLElement }; + } | null>(null); const hoverTimeoutRef = useRef(null); const stickySentinelRef = useRef(null); const [isStuck, setIsStuck] = useState(false); @@ -167,6 +173,11 @@ export const Viewer = forwardRef(({ // In pinpoint mode, apply code block annotation based on current editor mode if (modeRef.current === 'redline') { applyCodeBlockAnnotation(blockId, codeEl, AnnotationType.DELETION); + } else if (modeRef.current === 'quickLabel') { + setQuickLabelPicker({ + anchorEl: element, + codeBlock: { block: blocks.find(b => b.id === blockId)!, element }, + }); } else { // Show comment popover anchored to the code block setCommentPopover({ @@ -182,7 +193,7 @@ export const Viewer = forwardRef(({ containerRef, highlighterRef, inputMethod, - enabled: !toolbarState && !commentPopover && !(isPlanDiffActive ?? false), + enabled: !toolbarState && !commentPopover && !quickLabelPicker && !(isPlanDiffActive ?? false), onCodeBlockClick: handlePinpointCodeBlockClick, }); @@ -543,6 +554,7 @@ export const Viewer = forwardRef(({ pendingSourceRef.current = null; } setCommentPopover(null); + setQuickLabelPicker(null); if (modeRef.current === 'redline') { // Auto-delete in redline mode @@ -557,6 +569,13 @@ export const Viewer = forwardRef(({ isGlobal: false, source, }); + } else if (modeRef.current === 'quickLabel') { + // Quick Label mode - show floating label picker directly + pendingSourceRef.current = source; + setQuickLabelPicker({ + anchorEl: doms[0] as HTMLElement, + source, + }); } else { // Selection mode - show toolbar menu const selectionText = source.text; @@ -695,6 +714,38 @@ export const Viewer = forwardRef(({ window.getSelection()?.removeAllRanges(); }; + const handleFloatingQuickLabel = useCallback((label: QuickLabel) => { + if (!quickLabelPicker) return; + + if (quickLabelPicker.source && highlighterRef.current) { + createAnnotationFromSource( + highlighterRef.current, quickLabelPicker.source, AnnotationType.COMMENT, + `${label.emoji} ${label.text}`, undefined, true + ); + pendingSourceRef.current = null; + } else if (quickLabelPicker.codeBlock) { + const codeEl = quickLabelPicker.codeBlock.element.querySelector('code'); + if (codeEl) { + applyCodeBlockAnnotation( + quickLabelPicker.codeBlock.block.id, codeEl, AnnotationType.COMMENT, + `${label.emoji} ${label.text}`, undefined, true + ); + } + } + + setQuickLabelPicker(null); + window.getSelection()?.removeAllRanges(); + }, [quickLabelPicker]); + + const handleQuickLabelPickerDismiss = useCallback(() => { + if (quickLabelPicker?.source && highlighterRef.current) { + highlighterRef.current.remove(quickLabelPicker.source.id); + pendingSourceRef.current = null; + } + setQuickLabelPicker(null); + window.getSelection()?.removeAllRanges(); + }, [quickLabelPicker]); + const handleToolbarClose = () => { if (toolbarState && highlighterRef.current) { highlighterRef.current.remove(toolbarState.source.id); @@ -1071,6 +1122,15 @@ export const Viewer = forwardRef(({ onClose={handleCommentClose} /> )} + + {/* Quick Label floating picker (quickLabel mode) */} + {quickLabelPicker && ( + + )} ); diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 4b71fde4..15286390 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -6,7 +6,7 @@ export enum AnnotationType { GLOBAL_COMMENT = 'GLOBAL_COMMENT', } -export type EditorMode = 'selection' | 'comment' | 'redline'; +export type EditorMode = 'selection' | 'comment' | 'redline' | 'quickLabel'; export type InputMethod = 'drag' | 'pinpoint'; diff --git a/packages/ui/utils/editorMode.ts b/packages/ui/utils/editorMode.ts index d5baac0d..40b95f60 100644 --- a/packages/ui/utils/editorMode.ts +++ b/packages/ui/utils/editorMode.ts @@ -17,7 +17,7 @@ const DEFAULT_MODE: EditorMode = 'selection'; */ export function getEditorMode(): EditorMode { const stored = storage.getItem(STORAGE_KEY); - if (stored === 'selection' || stored === 'comment' || stored === 'redline') { + if (stored === 'selection' || stored === 'comment' || stored === 'redline' || stored === 'quickLabel') { return stored; } return DEFAULT_MODE; From 68a9bb881e944c626ef7bcb7c59c64c88dce4357 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 11 Mar 2026 21:29:39 -0700 Subject: [PATCH 3/5] 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 --- packages/ui/components/AnnotationToolbar.tsx | 41 ++-- .../components/FloatingQuickLabelPicker.tsx | 105 +++++++---- packages/ui/components/KeyboardShortcuts.tsx | 2 +- packages/ui/components/QuickLabelDropdown.tsx | 79 +++++--- packages/ui/components/Settings.tsx | 178 +++++++++++++----- packages/ui/components/Viewer.tsx | 23 ++- packages/ui/hooks/useInputMethodSwitch.ts | 2 + packages/ui/types.ts | 1 + packages/ui/utils/parser.ts | 3 + packages/ui/utils/quickLabels.ts | 6 +- 10 files changed, 295 insertions(+), 145 deletions(-) diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index 59176223..494470de 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -1,9 +1,9 @@ -import React, { useState, useEffect, useRef, useMemo } from "react"; +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { AnnotationType } from "../types"; import { createPortal } from "react-dom"; import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape"; import { type QuickLabel, getQuickLabels } from "../utils/quickLabels"; -import { QuickLabelDropdown } from "./QuickLabelDropdown"; +import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker"; type PositionMode = 'center-above' | 'top-right'; @@ -51,6 +51,7 @@ export const AnnotationToolbar: React.FC = ({ const [copied, setCopied] = useState(false); const [showQuickLabels, setShowQuickLabels] = useState(false); const toolbarRef = useRef(null); + const zapButtonRef = useRef(null); const quickLabels = useMemo(() => getQuickLabels(), []); const handleCopy = async () => { @@ -114,7 +115,7 @@ export const AnnotationToolbar: React.FC = ({ } // Alt+1..8: apply quick label - if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit8') { + if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit9') { e.preventDefault(); const index = parseInt(e.code.slice(5), 10) - 1; if (index < quickLabels.length) { @@ -135,7 +136,7 @@ export const AnnotationToolbar: React.FC = ({ }, [onClose, onRequestComment, onQuickLabel, quickLabels]); useDismissOnOutsideAndEscape({ - enabled: true, + enabled: !showQuickLabels, ref: toolbarRef, onDismiss: onClose, }); @@ -203,23 +204,25 @@ export const AnnotationToolbar: React.FC = ({ className="text-accent hover:bg-accent/10" /> {onQuickLabel && ( -
+ <> setShowQuickLabels(prev => !prev)} icon={} label="Quick label" className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"} /> - {showQuickLabels && ( - { setShowQuickLabels(false); onQuickLabel(label); }} + onDismiss={() => setShowQuickLabels(false)} /> )} -
+ )}
= ({ ); }; -// Inline dropdown wrapper positioned below the zap button -const InlineQuickLabelDropdown: React.FC<{ - labels: QuickLabel[]; - onSelect: (label: QuickLabel) => void; -}> = ({ labels, onSelect }) => ( -
- -
-); - // Icons const CopyIcon = () => ( @@ -284,17 +274,18 @@ const CloseIcon = () => ( ); -const ToolbarButton: React.FC<{ +const ToolbarButton = React.forwardRef void; icon: React.ReactNode; label: string; className: string; -}> = ({ onClick, icon, label, className }) => ( +}>(({ onClick, icon, label, className }, ref) => ( -); +)); diff --git a/packages/ui/components/FloatingQuickLabelPicker.tsx b/packages/ui/components/FloatingQuickLabelPicker.tsx index 1210f2c4..76d0f685 100644 --- a/packages/ui/components/FloatingQuickLabelPicker.tsx +++ b/packages/ui/components/FloatingQuickLabelPicker.tsx @@ -5,45 +5,65 @@ import { QuickLabelDropdown } from './QuickLabelDropdown'; interface FloatingQuickLabelPickerProps { anchorEl: HTMLElement; + /** Mouse coordinates at the moment of selection β€” picker appears here */ + cursorHint?: { x: number; y: number }; onSelect: (label: QuickLabel) => void; onDismiss: () => void; } +const PICKER_WIDTH = 192; +const GAP = 6; +const VIEWPORT_PADDING = 12; + +function computePosition( + anchorEl: HTMLElement, + cursorHint?: { x: number; y: number }, +): { top: number; left: number; flipAbove: boolean } { + const rect = anchorEl.getBoundingClientRect(); + + // Vertical: use anchor rect for above/below decision + placement + const spaceBelow = window.innerHeight - rect.bottom; + const flipAbove = spaceBelow < 220; + const top = flipAbove ? rect.top - GAP : rect.bottom + GAP; + + // Horizontal: prefer cursor x, fallback to anchor right edge + let left: number; + if (cursorHint) { + // Anchor left edge of picker at cursor x, nudge left slightly so + // the first row's text is directly under the pointer + left = cursorHint.x - 28; + } else { + // Fallback: right edge of anchor (where selection likely ended) + left = rect.right - PICKER_WIDTH / 2; + } + + // Clamp to viewport + left = Math.max(VIEWPORT_PADDING, Math.min(left, window.innerWidth - PICKER_WIDTH - VIEWPORT_PADDING)); + + return { top, left, flipAbove }; +} + export const FloatingQuickLabelPicker: React.FC = ({ anchorEl, + cursorHint, onSelect, onDismiss, }) => { - const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + const [position, setPosition] = useState<{ top: number; left: number; flipAbove: boolean } | null>(null); const ref = useRef(null); const quickLabels = useMemo(() => getQuickLabels(), []); // Position tracking useEffect(() => { - const updatePosition = () => { - const rect = anchorEl.getBoundingClientRect(); - const dropdownHeight = 120; // approximate - const gap = 8; - - const hasSpaceAbove = rect.top > dropdownHeight + gap; - const top = hasSpaceAbove - ? rect.top - gap - : rect.bottom + gap; - - setPosition({ - top, - left: rect.left + rect.width / 2, - }); - }; - - updatePosition(); - window.addEventListener('scroll', updatePosition, true); - window.addEventListener('resize', updatePosition); + const update = () => setPosition(computePosition(anchorEl, cursorHint)); + update(); + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); return () => { - window.removeEventListener('scroll', updatePosition, true); - window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); }; - }, [anchorEl]); + }, [anchorEl, cursorHint]); // Keyboard: Alt+1..8 and Escape useEffect(() => { @@ -53,60 +73,67 @@ export const FloatingQuickLabelPicker: React.FC = onDismiss(); return; } - if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit8') { + if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit9') { e.preventDefault(); const index = parseInt(e.code.slice(5), 10) - 1; if (index < quickLabels.length) { onSelect(quickLabels[index]); } - return; } }; - window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onDismiss, onSelect, quickLabels]); // Click outside to dismiss useEffect(() => { - const handleClick = (e: MouseEvent) => { + const handlePointerDown = (e: PointerEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { onDismiss(); } }; - - // Use setTimeout to avoid dismissing from the same click that triggered the picker + // Defer to avoid catching the triggering click const timer = setTimeout(() => { - document.addEventListener('mousedown', handleClick); + document.addEventListener('pointerdown', handlePointerDown, true); }, 0); - return () => { clearTimeout(timer); - document.removeEventListener('mousedown', handleClick); + document.removeEventListener('pointerdown', handlePointerDown, true); }; }, [onDismiss]); if (!position) return null; + const animName = position.flipAbove ? 'qlp-in-above' : 'qlp-in-below'; + return createPortal(
e.stopPropagation()} > - + +
+ +
, document.body ); diff --git a/packages/ui/components/KeyboardShortcuts.tsx b/packages/ui/components/KeyboardShortcuts.tsx index 986a40dc..6794cd5c 100644 --- a/packages/ui/components/KeyboardShortcuts.tsx +++ b/packages/ui/components/KeyboardShortcuts.tsx @@ -94,7 +94,7 @@ const planShortcuts: ShortcutSection[] = [ title: 'Annotations', shortcuts: [ { keys: ['a-z'], desc: 'Start typing comment', hint: 'When the annotation toolbar is open, any letter key opens the comment editor with that character' }, - { keys: [alt, '1-8'], desc: 'Apply quick label', hint: 'When the toolbar or label picker is open, instantly applies the Nth preset label' }, + { keys: [alt, '1-9'], desc: 'Apply quick label', hint: 'When the toolbar or label picker is open, instantly applies the Nth preset label' }, { keys: [mod, enter], desc: 'Submit comment' }, { keys: [mod, 'C'], desc: 'Copy selected text' }, { keys: ['Esc'], desc: 'Close toolbar / Cancel' }, diff --git a/packages/ui/components/QuickLabelDropdown.tsx b/packages/ui/components/QuickLabelDropdown.tsx index d41d42bd..df634182 100644 --- a/packages/ui/components/QuickLabelDropdown.tsx +++ b/packages/ui/components/QuickLabelDropdown.tsx @@ -1,36 +1,63 @@ import React from 'react'; import { type QuickLabel, getLabelColors } from '../utils/quickLabels'; +/** + * Shared vertical label list used by both FloatingQuickLabelPicker + * and the AnnotationToolbar's inline dropdown. + * + * Context-menu style: single column, colored accent bars, full-width rows. + */ export const QuickLabelDropdown: React.FC<{ labels: QuickLabel[]; onSelect: (label: QuickLabel) => void; -}> = ({ labels, onSelect }) => { - const isMac = navigator.platform?.includes('Mac'); - const altKey = isMac ? 'βŒ₯' : 'Alt+'; - + /** Enable staggered row entrance animation */ + animate?: boolean; +}> = ({ labels, onSelect, animate = false }) => { return ( -
e.stopPropagation()}> -
Quick Labels
-
- {labels.map((label, index) => { - const colors = getLabelColors(label.color); - return ( - - ); - })} -
+
e.stopPropagation()}> + {animate && ( + + )} + {labels.map((label, index) => { + const colors = getLabelColors(label.color); + return ( + + ); + })}
); }; diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 9c51c3a6..b1a22105 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -78,6 +78,8 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const [autoCloseDelay, setAutoCloseDelayState] = useState('off'); const [defaultNotesApp, setDefaultNotesApp] = useState('ask'); const [quickLabelsState, setQuickLabelsState] = useState([]); + const [editingTipIndex, setEditingTipIndex] = useState(null); + const [editingTipValue, setEditingTipValue] = useState(''); // Fetch available agents for OpenCode const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin ?? null); @@ -641,57 +643,137 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange
+
{quickLabelsState.map((label, index) => { const colors = getLabelColors(label.color); + const hasTip = !!label.tip; + const isEditingTip = editingTipIndex === index; return ( -
- {label.emoji} - { - const updated = [...quickLabelsState]; - updated[index] = { - ...label, - text: e.target.value, - id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''), - }; - setQuickLabelsState(updated); - saveQuickLabels(updated); - }} - className="flex-1 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" - /> - - - {index < 8 ? `${navigator.platform?.includes('Mac') ? 'βŒ₯' : 'Alt+'}${index + 1}` : ''} - - +
+ {/* Main row */} +
+ {label.emoji} + { + const updated = [...quickLabelsState]; + updated[index] = { + ...label, + text: e.target.value, + id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''), + }; + setQuickLabelsState(updated); + saveQuickLabels(updated); + }} + className="flex-1 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + {/* Tip indicator button */} + + + + {index < 9 ? `${navigator.platform?.includes('Mac') ? 'βŒ₯' : 'Alt+'}${index + 1}` : ''} + + +
+ {/* Tip editor β€” slides open below the row */} + {isEditingTip && ( +
+ + + + setEditingTipValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const updated = [...quickLabelsState]; + updated[index] = { ...label, tip: editingTipValue || undefined }; + setQuickLabelsState(updated); + saveQuickLabels(updated); + setEditingTipIndex(null); + } + if (e.key === 'Escape') setEditingTipIndex(null); + }} + placeholder="AI instruction tip..." + className="flex-1 px-2 py-1 bg-background/60 rounded text-[10px] text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-1 focus:ring-primary/50" + autoFocus + /> + +
+ )}
); })} @@ -717,7 +799,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange )}
- 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. + Use {navigator.platform?.includes('Mac') ? 'βŒ₯' : 'Alt+'}1 through {navigator.platform?.includes('Mac') ? 'βŒ₯' : 'Alt+'}9 when the annotation toolbar is visible to apply a label instantly.
)} diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index b32f1b6d..8af296a9 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -159,9 +159,11 @@ export const Viewer = forwardRef(({ } | null>(null); const [quickLabelPicker, setQuickLabelPicker] = useState<{ anchorEl: HTMLElement; + cursorHint?: { x: number; y: number }; source?: any; codeBlock?: { block: Block; element: HTMLElement }; } | null>(null); + const lastMousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const hoverTimeoutRef = useRef(null); const stickySentinelRef = useRef(null); const [isStuck, setIsStuck] = useState(false); @@ -252,6 +254,7 @@ export const Viewer = forwardRef(({ text?: string, images?: ImageAttachment[], isQuickLabel?: boolean, + quickLabelTip?: string, ) => { const doms = highlighter.getDoms(source.id); let blockId = ''; @@ -285,6 +288,7 @@ export const Viewer = forwardRef(({ endMeta: source.endMeta, images, ...(isQuickLabel ? { isQuickLabel: true } : {}), + ...(quickLabelTip ? { quickLabelTip } : {}), }; if (type === AnnotationType.DELETION) { @@ -531,6 +535,13 @@ export const Viewer = forwardRef(({ } }), [findTextInDOM, onSelectAnnotation]); + // Track last mouse position for cursor-anchored quick label picker + useEffect(() => { + const track = (e: MouseEvent) => { lastMousePosRef.current = { x: e.clientX, y: e.clientY }; }; + document.addEventListener('mouseup', track, true); + return () => document.removeEventListener('mouseup', track, true); + }, []); + useEffect(() => { if (!containerRef.current) return; @@ -574,6 +585,7 @@ export const Viewer = forwardRef(({ pendingSourceRef.current = source; setQuickLabelPicker({ anchorEl: doms[0] as HTMLElement, + cursorHint: lastMousePosRef.current, source, }); } else { @@ -707,7 +719,7 @@ export const Viewer = forwardRef(({ createAnnotationFromSource( highlighter, toolbarState.source, AnnotationType.COMMENT, - `${label.emoji} ${label.text}`, undefined, true + `${label.emoji} ${label.text}`, undefined, true, label.tip ); pendingSourceRef.current = null; setToolbarState(null); @@ -720,7 +732,7 @@ export const Viewer = forwardRef(({ if (quickLabelPicker.source && highlighterRef.current) { createAnnotationFromSource( highlighterRef.current, quickLabelPicker.source, AnnotationType.COMMENT, - `${label.emoji} ${label.text}`, undefined, true + `${label.emoji} ${label.text}`, undefined, true, label.tip ); pendingSourceRef.current = null; } else if (quickLabelPicker.codeBlock) { @@ -728,7 +740,7 @@ export const Viewer = forwardRef(({ if (codeEl) { applyCodeBlockAnnotation( quickLabelPicker.codeBlock.block.id, codeEl, AnnotationType.COMMENT, - `${label.emoji} ${label.text}`, undefined, true + `${label.emoji} ${label.text}`, undefined, true, label.tip ); } } @@ -762,6 +774,7 @@ export const Viewer = forwardRef(({ text?: string, images?: ImageAttachment[], isQuickLabel?: boolean, + quickLabelTip?: string, ) => { const id = `codeblock-${Date.now()}`; const codeText = codeEl.textContent || ''; @@ -786,6 +799,7 @@ export const Viewer = forwardRef(({ author: getIdentity(), images, ...(isQuickLabel ? { isQuickLabel: true } : {}), + ...(quickLabelTip ? { quickLabelTip } : {}), }; justCreatedIdRef.current = newAnnotation.id; @@ -807,7 +821,7 @@ export const Viewer = forwardRef(({ if (!codeEl) return; applyCodeBlockAnnotation( hoveredCodeBlock.block.id, codeEl, AnnotationType.COMMENT, - `${label.emoji} ${label.text}`, undefined, true + `${label.emoji} ${label.text}`, undefined, true, label.tip ); setHoveredCodeBlock(null); }; @@ -1127,6 +1141,7 @@ export const Viewer = forwardRef(({ {quickLabelPicker && ( diff --git a/packages/ui/hooks/useInputMethodSwitch.ts b/packages/ui/hooks/useInputMethodSwitch.ts index 6721fe64..9b7b891a 100644 --- a/packages/ui/hooks/useInputMethodSwitch.ts +++ b/packages/ui/hooks/useInputMethodSwitch.ts @@ -36,6 +36,8 @@ export function useInputMethodSwitch( // Don't interfere when user is typing const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return; + // Don't interfere when a quick label picker is open (it uses Alt+N shortcuts) + if (document.querySelector('[data-quick-label-picker]')) return; if (stateRef.current === 'idle') { // First press β€” switch immediately diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 15286390..e3f7cd75 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -27,6 +27,7 @@ export interface Annotation { author?: string; // Tater identity for collaborative sharing images?: ImageAttachment[]; // Attached images with human-readable names isQuickLabel?: boolean; // true if created via quick label chip + quickLabelTip?: string; // optional instruction tip from the label definition // web-highlighter metadata for cross-element selections startMeta?: { parentTagName: string; diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index 14fe91ff..80c6cb7a 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -298,6 +298,9 @@ export const exportAnnotations = (blocks: Block[], annotations: any[], globalAtt case 'COMMENT': if (ann.isQuickLabel) { output += `[${ann.text}] Feedback on: "${ann.originalText}"\n`; + if (ann.quickLabelTip) { + output += `> ${ann.quickLabelTip}\n`; + } } else { output += `Feedback on: "${ann.originalText}"\n`; output += `> ${ann.text}\n`; diff --git a/packages/ui/utils/quickLabels.ts b/packages/ui/utils/quickLabels.ts index b231d6ee..d0dc368c 100644 --- a/packages/ui/utils/quickLabels.ts +++ b/packages/ui/utils/quickLabels.ts @@ -14,6 +14,7 @@ export interface QuickLabel { emoji: string; // single emoji e.g. "πŸ§ͺ" text: string; // display text e.g. "Needs tests" color: string; // key into LABEL_COLOR_MAP + tip?: string; // optional instruction injected into feedback for the agent } /** Inline styles for label colors (avoids Tailwind dynamic class purging) */ @@ -29,12 +30,13 @@ export const LABEL_COLOR_MAP: Record Date: Wed, 11 Mar 2026 22:42:14 -0700 Subject: [PATCH 4/5] 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 --- packages/ui/components/AnnotationToolbar.tsx | 12 ++++++----- .../components/FloatingQuickLabelPicker.tsx | 9 +++++--- packages/ui/components/KeyboardShortcuts.tsx | 2 +- packages/ui/components/QuickLabelDropdown.tsx | 4 ++-- packages/ui/components/Settings.tsx | 5 +++-- packages/ui/utils/quickLabels.ts | 21 +++++++++++-------- 6 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index 494470de..5b295570 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -103,7 +103,7 @@ export const AnnotationToolbar: React.FC = ({ }; }, [element, positionMode, closeOnScrollOut, onClose]); - // Type-to-comment + Alt+N quick label shortcuts + // Type-to-comment + Alt+N / bare digit quick label shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.isComposing) return; @@ -114,10 +114,12 @@ export const AnnotationToolbar: React.FC = ({ return; } - // Alt+1..8: apply quick label - if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit9') { + // Quick label by digit β€” Alt+N always works, bare digit when picker is open + const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0'; + if (isDigit && !e.ctrlKey && !e.metaKey && (e.altKey || showQuickLabels)) { e.preventDefault(); - const index = parseInt(e.code.slice(5), 10) - 1; + const digit = parseInt(e.code.slice(5), 10); + const index = digit === 0 ? 9 : digit - 1; if (index < quickLabels.length) { onQuickLabel?.(quickLabels[index]); } @@ -133,7 +135,7 @@ export const AnnotationToolbar: React.FC = ({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [onClose, onRequestComment, onQuickLabel, quickLabels]); + }, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels]); useDismissOnOutsideAndEscape({ enabled: !showQuickLabels, diff --git a/packages/ui/components/FloatingQuickLabelPicker.tsx b/packages/ui/components/FloatingQuickLabelPicker.tsx index 76d0f685..f64f1e1c 100644 --- a/packages/ui/components/FloatingQuickLabelPicker.tsx +++ b/packages/ui/components/FloatingQuickLabelPicker.tsx @@ -65,7 +65,7 @@ export const FloatingQuickLabelPicker: React.FC = }; }, [anchorEl, cursorHint]); - // Keyboard: Alt+1..8 and Escape + // Keyboard: 1-9/0 or Alt+1-9/0 to apply label, Escape to dismiss useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -73,9 +73,12 @@ export const FloatingQuickLabelPicker: React.FC = onDismiss(); return; } - if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit9') { + // Accept bare digit or Alt+digit β€” picker is open so digits mean labels + const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0'; + if (isDigit && !e.ctrlKey && !e.metaKey) { e.preventDefault(); - const index = parseInt(e.code.slice(5), 10) - 1; + const digit = parseInt(e.code.slice(5), 10); + const index = digit === 0 ? 9 : digit - 1; if (index < quickLabels.length) { onSelect(quickLabels[index]); } diff --git a/packages/ui/components/KeyboardShortcuts.tsx b/packages/ui/components/KeyboardShortcuts.tsx index 6794cd5c..29acf831 100644 --- a/packages/ui/components/KeyboardShortcuts.tsx +++ b/packages/ui/components/KeyboardShortcuts.tsx @@ -94,7 +94,7 @@ const planShortcuts: ShortcutSection[] = [ title: 'Annotations', shortcuts: [ { keys: ['a-z'], desc: 'Start typing comment', hint: 'When the annotation toolbar is open, any letter key opens the comment editor with that character' }, - { keys: [alt, '1-9'], desc: 'Apply quick label', hint: 'When the toolbar or label picker is open, instantly applies the Nth preset label' }, + { 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.' }, { keys: [mod, enter], desc: 'Submit comment' }, { keys: [mod, 'C'], desc: 'Copy selected text' }, { keys: ['Esc'], desc: 'Close toolbar / Cancel' }, diff --git a/packages/ui/components/QuickLabelDropdown.tsx b/packages/ui/components/QuickLabelDropdown.tsx index df634182..6dcc3539 100644 --- a/packages/ui/components/QuickLabelDropdown.tsx +++ b/packages/ui/components/QuickLabelDropdown.tsx @@ -50,9 +50,9 @@ export const QuickLabelDropdown: React.FC<{ {label.text} {/* Shortcut hint */} - {index < 9 && ( + {index < 10 && ( - {index + 1} + {index === 9 ? '0' : index + 1} )} diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index b1a22105..6a234954 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -713,7 +713,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange ))} - {index < 9 ? `${navigator.platform?.includes('Mac') ? 'βŒ₯' : 'Alt+'}${index + 1}` : ''} + {index < 10 ? `${navigator.platform?.includes('Mac') ? 'βŒ₯' : 'Alt+'}${index === 9 ? '0' : index + 1}` : ''}