From 66da4ad8f473b64d3539783d565ef7446bc070c6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 4 Feb 2026 01:13:49 -0800 Subject: [PATCH] feat(text-selection): add text selection menu with quick actions (#283) Add TextSelectionMenu component that appears when selecting text in messages. Provides quick actions: Add to chat, New session, Copy. - Add TextSelectionMenu.tsx component with desktop and mobile layouts - Integrate menu into MessageBody with message-content-text ref - Add mobile CSS for text selection behavior --- .../components/chat/message/MessageBody.tsx | 34 +- .../chat/message/TextSelectionMenu.tsx | 312 ++++++++++++++++++ packages/ui/src/styles/mobile.css | 12 + 3 files changed, 343 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/components/chat/message/TextSelectionMenu.tsx diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 2efa1d94..dd05ab02 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -23,6 +23,7 @@ import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { flattenAssistantTextParts } from '@/lib/messages/messageText'; import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta'; +import { TextSelectionMenu } from './TextSelectionMenu'; const formatTurnDuration = (durationMs: number): string => { const totalSeconds = durationMs / 1000; @@ -287,6 +288,7 @@ const AssistantMessageBody: React.FC> = ({ void _allowAnimation; const [copyHintVisible, setCopyHintVisible] = React.useState(false); const copyHintTimeoutRef = React.useRef(null); + const messageContentRef = React.useRef(null); const canCopyMessage = Boolean(onCopyMessage); const isMessageCopied = Boolean(copiedMessage); @@ -886,22 +888,24 @@ const AssistantMessageBody: React.FC> = ({ ); - return ( + return ( -
-
-
+
+ +
+
{renderedParts} {showErrorMessage && ( diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx new file mode 100644 index 00000000..740baea5 --- /dev/null +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -0,0 +1,312 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react'; +import { cn } from '@/lib/utils'; + +interface TextSelectionMenuProps { + containerRef: React.RefObject; +} + +interface MenuPosition { + x: number; + y: number; + show: boolean; +} + +export const TextSelectionMenu: React.FC = ({ containerRef }) => { + const [position, setPosition] = React.useState({ x: 0, y: 0, show: false }); + const [selectedText, setSelectedText] = React.useState(''); + const [isDragging, setIsDragging] = React.useState(false); + const menuRef = React.useRef(null); + const pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null); + const createSession = useSessionStore((state) => state.createSession); + const setPendingInputText = useSessionStore((state) => state.setPendingInputText); + const isMobile = useUIStore((state) => state.isMobile); + + const hideMenu = React.useCallback(() => { + setPosition((prev) => ({ ...prev, show: false })); + setSelectedText(''); + pendingSelectionRef.current = null; + }, []); + + const showMenu = React.useCallback(() => { + if (!pendingSelectionRef.current) return; + + const { text, rect } = pendingSelectionRef.current; + + // Position menu above the selection + const menuX = rect.left + rect.width / 2; + const menuY = rect.top - 10; + + setSelectedText(text); + setPosition({ + x: menuX, + y: menuY, + show: true, + }); + }, []); + + const handleSelectionChange = React.useCallback(() => { + const selection = window.getSelection(); + const container = containerRef.current; + + if (!selection || !container) { + if (!isDragging) { + hideMenu(); + } + return; + } + + const text = selection.toString().trim(); + + // Only show if we have text and the selection is within our container + if (!text) { + if (!isDragging) { + hideMenu(); + } + return; + } + + // Check if selection is within the container + const range = selection.getRangeAt(0); + + if (!container.contains(range.commonAncestorContainer)) { + if (!isDragging) { + hideMenu(); + } + return; + } + + // Get selection coordinates + const rect = range.getBoundingClientRect(); + + // Store the selection but don't show menu yet if dragging + pendingSelectionRef.current = { text, rect }; + + // Only show menu if we're not currently dragging + if (!isDragging) { + showMenu(); + } + }, [containerRef, hideMenu, showMenu, isDragging]); + + React.useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Track when dragging starts + const handleMouseDown = () => { + setIsDragging(true); + hideMenu(); + }; + + // Track when dragging stops + const handleMouseUp = () => { + setIsDragging(false); + // Check if we have a pending selection to show + if (pendingSelectionRef.current) { + // Small delay to ensure selection is finalized + setTimeout(() => { + 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) && + !window.getSelection()?.toString().trim() + ) { + hideMenu(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + 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 (!selectedText) return; + + // Append to current input + const currentPending = useSessionStore.getState().pendingInputText || ''; + const newText = currentPending + ? `${currentPending} ${selectedText}` + : selectedText; + setPendingInputText(newText); + + hideMenu(); + + // Clear selection + window.getSelection()?.removeAllRanges(); + }, [selectedText, setPendingInputText, hideMenu]); + + const handleCreateNewSession = React.useCallback(async () => { + if (!selectedText) return; + + const session = await createSession(undefined, null, null); + if (session) { + setPendingInputText(selectedText); + } + + hideMenu(); + window.getSelection()?.removeAllRanges(); + }, [selectedText, createSession, setPendingInputText, hideMenu]); + + const handleCopy = React.useCallback(async () => { + if (!selectedText) return; + + try { + await navigator.clipboard.writeText(selectedText); + } catch (err) { + console.error('Failed to copy:', err); + } + + hideMenu(); + window.getSelection()?.removeAllRanges(); + }, [selectedText, hideMenu]); + + if (!position.show) return null; + + // Mobile: Show as a bar at the bottom of the screen, above the keyboard + if (isMobile) { + return createPortal( +
+ + + + + +
, + document.body + ); + } + + // Desktop: Show as a popup above the selection + return createPortal( +
+ + +
+ + +
, + document.body + ); +}; + +export default TextSelectionMenu; diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index 68ed3eea..bb96429e 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -119,6 +119,18 @@ pointer-events: auto; } + /* Prevent system context menu on message content when using custom selection menu */ + :root.mobile-pointer:not(.desktop-runtime) .message-content-text { + -webkit-touch-callout: none; + -webkit-user-select: text; + user-select: text; + } + + /* Allow system context menu only when explicitly requested via the "More" button */ + :root.mobile-pointer:not(.desktop-runtime) .message-content-text.show-system-menu { + -webkit-touch-callout: default; + } + /* Improve mobile spacing */ :root.mobile-pointer:not(.desktop-runtime) .px-4 { padding-left: 1rem !important;