import React from 'react'; import { createPortal } from 'react-dom'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { useSessions } from '@/sync/sync-context'; import { useInputStore } from '@/sync/input-store'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi'; import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { summarizeSelectionForNotes } from '@/lib/smallModel'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat'; import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects'; import { DESKTOP_MENU_FALLBACK_HEIGHT_PX, DESKTOP_MENU_FALLBACK_WIDTH_PX, getDesktopClampedX, getDesktopClampedY, } from './selectionMenuPosition'; interface TextSelectionMenuProps { containerRef: React.RefObject; } interface MenuPosition { x: number; y: number; show: boolean; } interface SelectionPayload { plainText: string; markdownText: string; rect: DOMRect; messageId: string | null; range: Range; } const normalizeDistilledInsight = (insight: string): string => ( insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH) ); export const TextSelectionMenu: React.FC = ({ containerRef }) => { const { t } = useI18n(); const [position, setPosition] = React.useState({ x: 0, y: 0, show: false }); const [selectedText, setSelectedText] = React.useState(''); const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState(''); const [selectedMessageId, setSelectedMessageId] = React.useState(null); const [commentMode, setCommentMode] = React.useState(false); const commentModeRef = React.useRef(false); const [commentText, setCommentText] = React.useState(''); const commentInputRef = React.useRef(null); // While the comment input owns focus the native selection is gone, so the // quoted fragment is repainted with our own overlay rectangles. Raw // Range.getClientRects() mixes block-container boxes with text boxes and // the translucent overlaps paint double-dark bands, so the rects are taken // from the text nodes only and merged into one strip per visual line. const [commentRects, setCommentRects] = React.useState(null); const updateCommentRects = React.useCallback(() => { const range = pendingSelectionRef.current?.range; if (!range) { setCommentRects(null); return; } setCommentRects(collectSelectionOverlayRects(range)); }, []); React.useEffect(() => { if (!commentMode) return; let frame: number | null = null; const scheduleUpdate = () => { if (frame !== null) return; frame = window.requestAnimationFrame(() => { frame = null; updateCommentRects(); }); }; document.addEventListener('scroll', scheduleUpdate, { capture: true, passive: true }); window.addEventListener('resize', scheduleUpdate); return () => { if (frame !== null) window.cancelAnimationFrame(frame); document.removeEventListener('scroll', scheduleUpdate, { capture: true }); window.removeEventListener('resize', scheduleUpdate); }; }, [commentMode, updateCommentRects]); // Grow the comment box with its content, up to five lines. const resizeCommentInput = React.useCallback(() => { const element = commentInputRef.current; if (!element) return; element.style.height = 'auto'; element.style.height = `${Math.min(element.scrollHeight, 120)}px`; }, []); const isDraggingRef = React.useRef(false); const [isOpening, setIsOpening] = React.useState(false); const [isAddingToNotes, setIsAddingToNotes] = React.useState(false); const menuRef = React.useRef(null); const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX); const menuHeightRef = React.useRef(DESKTOP_MENU_FALLBACK_HEIGHT_PX); const pendingSelectionRef = React.useRef(null); const openRafRef = React.useRef(null); const mouseUpTimeoutRef = React.useRef(null); const isMenuVisibleRef = React.useRef(false); const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft); const setPendingInputText = useInputStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); const projects = useProjectsStore((state) => state.projects); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const effectiveDirectory = useEffectiveDirectory(); const sessions = useSessions(); // Mobile: the comment bar is rendered inside the composer form (its // positioning context), so it inherits the runtime's own keyboard handling // — browser viewport resizing and Capacitor choreography alike. This effect // only centers it on the composer pill in the form's local coordinates; no // viewport math, which Safari's keyboard handling reliably breaks for // fixed elements. React.useEffect(() => { if (!commentMode || !isMobile) return; const update = () => { const element = menuRef.current; const host = element?.offsetParent; if (!element || !host) return; const pill = document.querySelector('[data-mobile-composer-pill="true"]') ?? document.querySelector('[data-chat-input="true"]'); const pillRect = pill?.getBoundingClientRect(); if (!pillRect || pillRect.height <= 0) return; const hostRect = host.getBoundingClientRect(); element.style.top = `${pillRect.top - hostRect.top + (pillRect.height - element.offsetHeight) / 2}px`; element.style.left = `${pillRect.left - hostRect.left}px`; element.style.width = `${pillRect.width}px`; element.style.bottom = 'auto'; }; update(); const raf = window.requestAnimationFrame(update); // The composer relayouts with its own transitions and timeouts that emit // no event; a light poll keeps the overlay glued to the pill. const poll = window.setInterval(update, 200); return () => { window.cancelAnimationFrame(raf); window.clearInterval(poll); }; }, [commentMode, isMobile]); React.useEffect(() => { isMenuVisibleRef.current = position.show; }, [position.show]); React.useEffect(() => { return () => { activeAddToChatCleanupRef.current?.(); activeAddToChatCleanupRef.current = null; if (openRafRef.current !== null) { window.cancelAnimationFrame(openRafRef.current); openRafRef.current = null; } if (mouseUpTimeoutRef.current !== null) { window.clearTimeout(mouseUpTimeoutRef.current); mouseUpTimeoutRef.current = null; } }; }, []); const hideMenu = React.useCallback(() => { pendingSelectionRef.current = null; activeAddToChatCleanupRef.current?.(); activeAddToChatCleanupRef.current = null; setCommentRects(null); if (!isMenuVisibleRef.current) { return; } if (openRafRef.current !== null) { window.cancelAnimationFrame(openRafRef.current); openRafRef.current = null; } setIsOpening(false); setPosition((prev) => ({ ...prev, show: false })); setSelectedText(''); setSelectedTextMarkdown(''); setSelectedMessageId(null); setCommentMode(false); commentModeRef.current = false; setCommentText(''); isMenuVisibleRef.current = false; }, []); const getClampedX = React.useCallback((anchorX: number) => ( getDesktopClampedX(anchorX, window.innerWidth, menuWidthRef.current) ), []); const getClampedY = React.useCallback((anchorY: number) => ( getDesktopClampedY(anchorY, window.innerHeight, menuHeightRef.current) ), []); const addMarkdownToChat = React.useCallback((markdownText: string) => { const markdownBlock = wrapMarkdownSelectionForChat(markdownText); setPendingInputText(markdownBlock, 'append'); hideMenu(); window.getSelection()?.removeAllRanges(); queueMicrotask(() => { focusChatInput(); }); }, [hideMenu, setPendingInputText]); const showMenu = React.useCallback(() => { if (!pendingSelectionRef.current) return; const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current; const shouldAnimateIn = !position.show; activeAddToChatCleanupRef.current?.(); activeAddToChatCleanupRef.current = registerActiveSelectionToolbar({ addToChat: () => addMarkdownToChat(markdownText), dismiss: hideMenu, }); // Position menu above the selection const menuX = isMobile ? rect.left + rect.width / 2 : getClampedX(rect.left + rect.width / 2); const menuY = isMobile ? rect.top - 10 : getClampedY(rect.top - 10); setSelectedText(plainText); setSelectedTextMarkdown(markdownText); setSelectedMessageId(messageId); setPosition({ x: menuX, y: menuY, show: true, }); isMenuVisibleRef.current = true; if (shouldAnimateIn) { setIsOpening(true); if (openRafRef.current !== null) { window.cancelAnimationFrame(openRafRef.current); } openRafRef.current = window.requestAnimationFrame(() => { setIsOpening(false); openRafRef.current = null; }); } }, [addMarkdownToChat, getClampedX, getClampedY, hideMenu, isMobile, position.show]); React.useLayoutEffect(() => { if (!position.show || isMobile || !menuRef.current) { return; } const measuredWidth = menuRef.current.offsetWidth; const measuredHeight = menuRef.current.offsetHeight; const widthChanged = Number.isFinite(measuredWidth) && measuredWidth > 0 && measuredWidth !== menuWidthRef.current; const heightChanged = Number.isFinite(measuredHeight) && measuredHeight > 0 && measuredHeight !== menuHeightRef.current; if (!widthChanged && !heightChanged) { return; } if (widthChanged) { menuWidthRef.current = measuredWidth; } if (heightChanged) { menuHeightRef.current = measuredHeight; } setPosition((prev) => ({ ...prev, x: getClampedX(prev.x), y: getClampedY(prev.y), })); }, [getClampedX, getClampedY, isMobile, position.show]); // The desktop popup hangs above its anchor, so a tall comment box near the // top of the chat can climb over the app header. On the desktop shell the // header is a window drag zone, which makes the overlapped part of the // textarea untouchable, so the popup is pushed down until its top edge stays // inside the chat container. React.useLayoutEffect(() => { if (!position.show || isMobile || !menuRef.current) { return; } const container = containerRef.current; const minTop = (container ? container.getBoundingClientRect().top : 0) + 4; const menuTop = menuRef.current.getBoundingClientRect().top; if (menuTop < minTop) { const delta = minTop - menuTop; setPosition((prev) => ({ ...prev, y: prev.y + delta })); } }, [containerRef, isMobile, position.show, position.y, commentMode, commentText]); React.useEffect(() => { if (!position.show || isMobile) { return; } const handleViewportResize = () => { setPosition((prev) => ({ ...prev, x: getClampedX(prev.x), y: getClampedY(prev.y), })); }; window.addEventListener('resize', handleViewportResize); return () => { window.removeEventListener('resize', handleViewportResize); }; }, [getClampedX, getClampedY, isMobile, position.show]); const handleSelectionChange = React.useCallback(() => { // While the comment input is open, clicking or typing in it collapses the // text selection; the captured quote must survive that. if (commentModeRef.current) { return; } const selection = window.getSelection(); const container = containerRef.current; if (!selection || !container) { if (!isDraggingRef.current) { hideMenu(); } return; } const text = trimSelectionValue(selection.toString()); // Only show if we have text and the selection is within our container if (!text) { if (!isDraggingRef.current) { hideMenu(); } return; } // Check if selection is within the container const range = selection.getRangeAt(0); if (!container.contains(range.commonAncestorContainer)) { if (!isDraggingRef.current) { hideMenu(); } return; } // Get selection coordinates const rect = range.getBoundingClientRect(); // Store the selection but don't show menu yet if dragging const anchorElement = range.commonAncestorContainer instanceof Element ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement; pendingSelectionRef.current = { plainText: text, markdownText: rangeToMarkdown(range, text), rect, messageId: anchorElement?.closest('[data-message-id]')?.getAttribute('data-message-id') ?? null, range: range.cloneRange(), }; // Only show menu if we're not currently dragging if (!isDraggingRef.current) { showMenu(); } }, [containerRef, hideMenu, showMenu]); React.useEffect(() => { const container = containerRef.current; if (!container) return; // Track when dragging starts const handleMouseDown = (event: MouseEvent) => { // SAFETY: a MouseEvent target inside the document is always a Node; // `contains` only needs that. if (commentModeRef.current && menuRef.current?.contains(event.target as Node)) { return; } isDraggingRef.current = true; hideMenu(); }; // Track when dragging stops const handleMouseUp = () => { isDraggingRef.current = false; // Check if we have a pending selection to show if (pendingSelectionRef.current) { if (mouseUpTimeoutRef.current !== null) { window.clearTimeout(mouseUpTimeoutRef.current); } // Small delay to ensure selection is finalized mouseUpTimeoutRef.current = window.setTimeout(() => { mouseUpTimeoutRef.current = null; // The click that opened the comment input cleared the selection on // purpose; the input must survive this deferred check. if (commentModeRef.current) { return; } const selection = window.getSelection(); if (selection && selection.toString().trim()) { showMenu(); } else { hideMenu(); } }, 10); } }; // Listen for selection changes during drag document.addEventListener('selectionchange', handleSelectionChange); container.addEventListener('mousedown', handleMouseDown); document.addEventListener('mouseup', handleMouseUp); // Hide menu when clicking outside const handleClickOutside = (e: MouseEvent) => { if ( menuRef.current && !menuRef.current.contains(e.target as Node) && (commentModeRef.current || !window.getSelection()?.toString().trim()) ) { hideMenu(); } }; document.addEventListener('mousedown', handleClickOutside); return () => { if (mouseUpTimeoutRef.current !== null) { window.clearTimeout(mouseUpTimeoutRef.current); mouseUpTimeoutRef.current = null; } document.removeEventListener('selectionchange', handleSelectionChange); container.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mousedown', handleClickOutside); }; }, [containerRef, handleSelectionChange, hideMenu, showMenu]); const handleAddToChat = React.useCallback(() => { if (!selectedTextMarkdown) return; addMarkdownToChat(selectedTextMarkdown); }, [addMarkdownToChat, selectedTextMarkdown]); const handleOpenComment = React.useCallback(() => { if (!selectedTextMarkdown) return; setCommentMode(true); commentModeRef.current = true; updateCommentRects(); window.getSelection()?.removeAllRanges(); queueMicrotask(() => { commentInputRef.current?.focus(); }); }, [selectedTextMarkdown, updateCommentRects]); const handleAttachComment = React.useCallback(() => { const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); if (!selectedTextMarkdown || !sessionKey || !effectiveDirectory) { hideMenu(); return; } addContextDraft({ directory: effectiveDirectory, sessionKey }, { source: 'chat-quote', fileLabel: selectedMessageId ?? '', startLine: 1, endLine: 1, code: selectedTextMarkdown, language: '', text: commentText.trim(), }); hideMenu(); queueMicrotask(() => { focusChatInput(); }); }, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]); const currentSession = React.useMemo(() => { if (!currentSessionId) { return null; } return sessions.find((session) => session.id === currentSessionId) ?? null; }, [currentSessionId, sessions]); const currentProjectRef = React.useMemo(() => { const directory = effectiveDirectory ?? (typeof currentSession?.directory === 'string' ? currentSession.directory : ''); const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory); return resolved ? { id: resolved.id, path: resolved.path } : null; }, [availableWorktreesByProject, currentSession?.directory, effectiveDirectory, projects]); const handleAddToNotes = React.useCallback(async () => { if (!selectedText || !currentProjectRef) { if (!currentProjectRef) { toast.error(t('chat.textSelection.toast.noProject')); } return; } try { setIsAddingToNotes(true); // Long selections are distilled into a compact note by the small model; // short ones (and any generation failure) go in verbatim. const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId); const insight = normalizeDistilledInsight(noteText); if (!insight) { toast.error(t('chat.textSelection.toast.addToNotesFailed')); return; } // Recorded as its own note with provenance, so the distilled insight can // later be traced back to the conversation it came from. const saved = await useProjectContextStore.getState().createNote(currentProjectRef, { body: insight, source: 'selection', ...(currentSessionId ? { origin: { sessionId: currentSessionId } } : {}), }); if (!saved) { toast.error(t('chat.textSelection.toast.addToNotesFailed')); return; } toast.success(t('chat.textSelection.toast.addToNotesSuccess')); hideMenu(); window.getSelection()?.removeAllRanges(); } catch (error) { const description = error instanceof Error ? error.message : undefined; toast.error(t('chat.textSelection.toast.addToNotesFailed'), description ? { description } : undefined); } finally { setIsAddingToNotes(false); } }, [currentProjectRef, currentSessionId, hideMenu, selectedText, selectedTextMarkdown, t]); if (!position.show) return null; const commentHighlightOverlay = commentMode && commentRects && commentRects.length > 0 ? createPortal(
{commentRects.map((rect, index) => (
))}
, document.body, ) : null; const commentInput = (