diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index da15625e..e1bcfaf6 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -57,7 +57,20 @@ function findTextNodeContaining(container: Element, text: string) { throw new Error(`Unable to find text node containing ${text}`) } -async function selectMessageText(element: Element, text: string) { +async function waitForSelectionMenuUpdate() { + await act(async () => { + await Promise.resolve() + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()) + }) + }) +} + +function prepareMessageTextSelection( + element: Element, + text: string, + rect: Partial = {}, +) { const textNode = findTextNodeContaining(element, text) const startOffset = textNode.textContent?.indexOf(text) ?? -1 const range = document.createRange() @@ -65,14 +78,14 @@ async function selectMessageText(element: Element, text: string) { range.setEnd(textNode, startOffset + text.length) Object.assign(range, { getBoundingClientRect: () => ({ - left: 160, - top: 80, - right: 280, - bottom: 98, - width: 120, - height: 18, - x: 160, - y: 80, + left: rect.left ?? 160, + top: rect.top ?? 80, + right: rect.right ?? 280, + bottom: rect.bottom ?? 98, + width: rect.width ?? 120, + height: rect.height ?? 18, + x: rect.x ?? rect.left ?? 160, + y: rect.y ?? rect.top ?? 80, toJSON: () => ({}), }), }) @@ -95,10 +108,21 @@ async function selectMessageText(element: Element, text: string) { window.getSelection()?.removeAllRanges() window.getSelection()?.addRange(range) + return selectableRoot ?? element +} + +async function selectMessageText( + element: Element, + text: string, + rect: Partial = {}, +) { + prepareMessageTextSelection(element, text, rect) + await act(async () => { fireEvent.mouseUp(element, { clientX: 260, clientY: 104 }) await Promise.resolve() }) + await waitForSelectionMenuUpdate() } describe('MessageList nested tool calls', () => { @@ -1654,6 +1678,85 @@ describe('MessageList nested tool calls', () => { expect(window.getSelection()?.toString()).toBe('') }) + it('shows the selected-message action when text selection ends outside the message', async () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [{ + id: 'assistant-1', + type: 'assistant_text', + content: 'Drag selection gestures can finish outside the message bubble.', + timestamp: 1, + }], + }), + }, + }) + + render() + + const assistantText = screen.getByText(/Drag selection gestures/) + prepareMessageTextSelection(assistantText, 'selection gestures') + + await act(async () => { + fireEvent.pointerDown(assistantText, { + button: 0, + clientX: 172, + clientY: 88, + pointerId: 1, + pointerType: 'mouse', + }) + fireEvent.pointerMove(document.body, { + clientX: 640, + clientY: 120, + pointerId: 1, + pointerType: 'mouse', + }) + fireEvent.pointerUp(document.body, { + clientX: 640, + clientY: 120, + pointerId: 1, + pointerType: 'mouse', + }) + await Promise.resolve() + }) + await waitForSelectionMenuUpdate() + + expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy() + }) + + it('places the selected-message action to the right when there is no room above', async () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [{ + id: 'assistant-1', + type: 'assistant_text', + content: 'Top edge selections need a nearby right-side action.', + timestamp: 1, + }], + }), + }, + }) + + render() + + const assistantText = screen.getByText(/Top edge selections/) + await selectMessageText(assistantText, 'right-side action', { + left: 160, + top: 18, + right: 280, + bottom: 36, + width: 120, + height: 18, + x: 160, + y: 18, + }) + const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' }) + + expect(floatingAddButton.style.left).toBe('290px') + expect(floatingAddButton.style.top).toBe('12px') + }) + it('adds selected assistant reply text to the composer context', async () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 722b8773..a6395fed 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -75,6 +75,11 @@ type ChatSelectionState = { y: number } +type SelectionPointer = { + clientX: number + clientY: number +} + const CHAT_SELECTION_MENU_OFFSET = 10 const CHAT_SELECTION_MENU_WIDTH = 158 const CHAT_SELECTION_MENU_HEIGHT = 44 @@ -95,7 +100,7 @@ function getChatSelectionPosition(range: Range, root: HTMLElement, pointer: { cl function getChatSelectionFromContainer( root: HTMLElement | null, - pointer: { clientX: number; clientY: number }, + pointer: SelectionPointer, ): ChatSelectionState | null { if (!root) return null const selection = window.getSelection() @@ -117,6 +122,13 @@ function getChatSelectionFromContainer( } } +function getSelectionPointer(event: SelectionPointer): SelectionPointer { + return { + clientX: event.clientX, + clientY: event.clientY, + } +} + function ChatSelectionMenu({ selection, onAdd, @@ -388,6 +400,8 @@ function SelectableChatMessage({ }) { const rootRef = useRef(null) const selectionMenuRef = useRef(null) + const selectionStartedInsideRef = useRef(false) + const selectionUpdateFrameRef = useRef(null) const addReference = useWorkspaceChatContextStore((state) => state.addReference) const [selectionMenu, setSelectionMenu] = useState(null) const t = useTranslation() @@ -397,12 +411,51 @@ function SelectableChatMessage({ useEffect(() => { setSelectionMenu(null) + selectionStartedInsideRef.current = false }, [content, messageId]) const dismissSelectionMenu = useCallback(() => { setSelectionMenu(null) }, []) + const queueSelectionMenuUpdate = useCallback((pointer: SelectionPointer) => { + if (selectionUpdateFrameRef.current !== null) { + window.cancelAnimationFrame(selectionUpdateFrameRef.current) + } + + selectionUpdateFrameRef.current = window.requestAnimationFrame(() => { + selectionUpdateFrameRef.current = null + setSelectionMenu(getChatSelectionFromContainer(rootRef.current, pointer)) + }) + }, []) + + useEffect(() => { + return () => { + if (selectionUpdateFrameRef.current !== null) { + window.cancelAnimationFrame(selectionUpdateFrameRef.current) + } + } + }, []) + + useEffect(() => { + const handlePointerUp = (event: PointerEvent) => { + if (!selectionStartedInsideRef.current) return + selectionStartedInsideRef.current = false + queueSelectionMenuUpdate(getSelectionPointer(event)) + } + + const handlePointerCancel = () => { + selectionStartedInsideRef.current = false + } + + document.addEventListener('pointerup', handlePointerUp, true) + document.addEventListener('pointercancel', handlePointerCancel, true) + return () => { + document.removeEventListener('pointerup', handlePointerUp, true) + document.removeEventListener('pointercancel', handlePointerCancel, true) + } + }, [queueSelectionMenuUpdate]) + useSelectionPopoverDismiss({ active: Boolean(selectionMenu), popoverRef: selectionMenuRef, @@ -426,8 +479,13 @@ function SelectableChatMessage({ return (
{ + if (event.pointerType === 'mouse' && event.button !== 0) return + selectionStartedInsideRef.current = true + }} onMouseUp={(event) => { - setSelectionMenu(getChatSelectionFromContainer(rootRef.current, event)) + selectionStartedInsideRef.current = false + queueSelectionMenuUpdate(getSelectionPointer(event)) }} onKeyDown={(event) => { if (event.key === 'Escape') setSelectionMenu(null) diff --git a/desktop/src/hooks/useSelectionPopoverDismiss.ts b/desktop/src/hooks/useSelectionPopoverDismiss.ts index 505efe42..32442e56 100644 --- a/desktop/src/hooks/useSelectionPopoverDismiss.ts +++ b/desktop/src/hooks/useSelectionPopoverDismiss.ts @@ -6,10 +6,48 @@ type ElementRef = { const VIEWPORT_MARGIN = 12 +type SelectionRect = { + left: number + top: number + right: number + bottom: number + width: number + height: number +} + function clampValue(value: number, min: number, max: number) { return Math.max(min, Math.min(value, max)) } +function isUsableRect(rect: SelectionRect | DOMRect) { + return rect.width > 0 || rect.height > 0 +} + +function getRangeSelectionRect(range: Range): SelectionRect | null { + const boundingRect = typeof range.getBoundingClientRect === 'function' + ? range.getBoundingClientRect() + : null + if (boundingRect && isUsableRect(boundingRect)) return boundingRect + + const clientRects = typeof range.getClientRects === 'function' + ? Array.from(range.getClientRects()).filter(isUsableRect) + : [] + if (clientRects.length === 0) return null + + const left = Math.min(...clientRects.map((rect) => rect.left)) + const top = Math.min(...clientRects.map((rect) => rect.top)) + const right = Math.max(...clientRects.map((rect) => rect.right)) + const bottom = Math.max(...clientRects.map((rect) => rect.bottom)) + return { + left, + top, + right, + bottom, + width: right - left, + height: bottom - top, + } +} + export function clearWindowSelection() { window.getSelection()?.removeAllRanges() } @@ -29,39 +67,54 @@ export function getSelectionPopoverPosition( fallbackPointer?: { clientX: number; clientY: number } }, ) { - const rect = typeof range.getBoundingClientRect === 'function' - ? range.getBoundingClientRect() - : null + const rect = getRangeSelectionRect(range) const rootRect = root.getBoundingClientRect() - const hasUsableRangeRect = Boolean(rect && (rect.width > 0 || rect.height > 0)) const pointerInsideRoot = fallbackPointer && fallbackPointer.clientX >= rootRect.left && fallbackPointer.clientX <= rootRect.right && fallbackPointer.clientY >= rootRect.top && fallbackPointer.clientY <= rootRect.bottom - const fallbackLeft = pointerInsideRoot ? fallbackPointer.clientX - menuWidth / 2 : rootRect.left + 24 - const fallbackTop = pointerInsideRoot ? fallbackPointer.clientY : rootRect.top + 24 - const selectionLeft = hasUsableRangeRect ? rect!.left : fallbackLeft - const selectionRight = hasUsableRangeRect ? rect!.right : selectionLeft + menuWidth - const selectionTop = hasUsableRangeRect ? rect!.top : fallbackTop - const selectionBottom = hasUsableRangeRect ? rect!.bottom : selectionTop + menuHeight + const fallbackX = pointerInsideRoot ? fallbackPointer.clientX : rootRect.left + 24 + const fallbackY = pointerInsideRoot ? fallbackPointer.clientY : rootRect.top + 24 + const selectionRect = rect ?? { + left: fallbackX, + top: fallbackY, + right: fallbackX, + bottom: fallbackY, + width: 0, + height: 0, + } const viewportWidth = window.innerWidth || document.documentElement.clientWidth || rootRect.right + VIEWPORT_MARGIN const viewportHeight = window.innerHeight || document.documentElement.clientHeight || rootRect.bottom + VIEWPORT_MARGIN const minX = VIEWPORT_MARGIN const maxX = Math.max(minX, viewportWidth - menuWidth - VIEWPORT_MARGIN) const minY = VIEWPORT_MARGIN const maxY = Math.max(minY, viewportHeight - menuHeight - VIEWPORT_MARGIN) - const aboveY = selectionTop - menuHeight - offset - const belowY = selectionBottom + offset - const y = aboveY >= VIEWPORT_MARGIN || belowY + menuHeight > viewportHeight - VIEWPORT_MARGIN - ? aboveY - : belowY - const centerX = selectionLeft + (selectionRight - selectionLeft) / 2 - - return { - x: clampValue(centerX - menuWidth / 2, minX, maxX), - y: clampValue(y, minY, maxY), + const clampPosition = (position: { x: number; y: number }) => ({ + x: clampValue(position.x, minX, maxX), + y: clampValue(position.y, minY, maxY), + }) + const centerX = selectionRect.left + selectionRect.width / 2 + const centerY = selectionRect.top + selectionRect.height / 2 + const above = { + x: centerX - menuWidth / 2, + y: selectionRect.top - menuHeight - offset, } + if (above.y >= VIEWPORT_MARGIN) return clampPosition(above) + + const right = { + x: selectionRect.right + offset, + y: centerY - menuHeight / 2, + } + if (right.x + menuWidth <= viewportWidth - VIEWPORT_MARGIN) return clampPosition(right) + + const below = { + x: centerX - menuWidth / 2, + y: selectionRect.bottom + offset, + } + if (below.y + menuHeight <= viewportHeight - VIEWPORT_MARGIN) return clampPosition(below) + + return clampPosition(above) } export function useSelectionPopoverDismiss({