diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 3db66ebd..2a33d50b 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -905,8 +905,8 @@ describe('MessageList nested tool calls', () => { await selectMessageText(userText, 'workspace selection behavior') const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' }) - expect(floatingAddButton.style.left).toBe('260px') - expect(floatingAddButton.style.top).toBe('112px') + expect(floatingAddButton.style.left).toBe('141px') + expect(floatingAddButton.style.top).toBe('26px') fireEvent.click(floatingAddButton) @@ -955,6 +955,80 @@ describe('MessageList nested tool calls', () => { ]) }) + it('dismisses the selected-message action when clicking outside the popover', async () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [{ + id: 'assistant-1', + type: 'assistant_text', + content: 'Clicking outside should clear this selected reply.', + timestamp: 1, + }], + }), + }, + }) + + render() + + const assistantText = screen.getByText(/Clicking outside should clear/) + await selectMessageText(assistantText, 'selected reply') + expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy() + + await act(async () => { + fireEvent.pointerDown(document.body) + await Promise.resolve() + }) + + expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull() + expect(window.getSelection()?.toString()).toBe('') + }) + + it('keeps only the latest selected-message action when selecting across messages', async () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'assistant-1', + type: 'assistant_text', + content: 'First assistant reply can be selected.', + timestamp: 1, + }, + { + id: 'assistant-2', + type: 'assistant_text', + content: 'Second assistant reply should replace it.', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + const firstText = screen.getByText(/First assistant reply/) + const secondText = screen.getByText(/Second assistant reply/) + await selectMessageText(firstText, 'First assistant reply') + expect(screen.getAllByRole('button', { name: 'Add to chat' })).toHaveLength(1) + + await act(async () => { + fireEvent.pointerDown(secondText) + await Promise.resolve() + }) + await selectMessageText(secondText, 'Second assistant reply') + + expect(screen.getAllByRole('button', { name: 'Add to chat' })).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: 'Add to chat' })) + expect(useWorkspaceChatContextStore.getState().referencesBySession[ACTIVE_TAB]).toMatchObject([ + { + messageId: 'assistant-2', + quote: 'Second assistant reply', + }, + ]) + }) + it('does not force-scroll to the bottom while the user is reading history', async () => { const scrollIntoView = vi.fn() Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 20b7e1b1..41108e9c 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1,5 +1,5 @@ import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react' -import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, LoaderCircle, Settings, Target, XCircle } from 'lucide-react' +import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react' import { ApiError } from '../../api/client' import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' @@ -22,6 +22,7 @@ import { InlineTaskSummary } from './InlineTaskSummary' import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' import type { AgentTaskNotification, UIMessage } from '../../types/chat' import { ConfirmDialog } from '../shared/ConfirmDialog' +import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss' type ToolCall = Extract type ToolResult = Extract @@ -62,11 +63,9 @@ type ChatSelectionState = { y: number } -const CHAT_SELECTION_MENU_OFFSET = 8 - -function clampValue(value: number, min: number, max: number) { - return Math.max(min, Math.min(value, max)) -} +const CHAT_SELECTION_MENU_OFFSET = 10 +const CHAT_SELECTION_MENU_WIDTH = 158 +const CHAT_SELECTION_MENU_HEIGHT = 44 function getElementForNode(node: Node | null): Element | null { if (!node) return null @@ -74,32 +73,12 @@ function getElementForNode(node: Node | null): Element | null { } function getChatSelectionPosition(range: Range, root: HTMLElement, pointer: { clientX: number; clientY: number }) { - const rect = typeof range.getBoundingClientRect === 'function' - ? range.getBoundingClientRect() - : null - const rootRect = root.getBoundingClientRect() - const pointerInsideRoot = - pointer.clientX >= rootRect.left && - pointer.clientX <= rootRect.right && - pointer.clientY >= rootRect.top && - pointer.clientY <= rootRect.bottom - const fallbackX = rect && rect.width > 0 - ? rect.left + rect.width / 2 - : rect?.left ?? rootRect.left + 24 - const fallbackY = rect - ? rect.bottom + CHAT_SELECTION_MENU_OFFSET - : rootRect.top + 24 - const unclampedX = pointerInsideRoot ? pointer.clientX : fallbackX - const unclampedY = pointerInsideRoot ? pointer.clientY + CHAT_SELECTION_MENU_OFFSET : fallbackY - const minX = Math.max(12, rootRect.left + 8) - const maxX = Math.max(minX, Math.min(window.innerWidth - 160, rootRect.right - 136)) - const minY = Math.max(12, rootRect.top + 8) - const maxY = Math.max(minY, Math.min(window.innerHeight - 48, rootRect.bottom - 40)) - - return { - x: clampValue(unclampedX, minX, maxX), - y: clampValue(unclampedY, minY, maxY), - } + return getSelectionPopoverPosition(range, root, { + menuWidth: CHAT_SELECTION_MENU_WIDTH, + menuHeight: CHAT_SELECTION_MENU_HEIGHT, + offset: CHAT_SELECTION_MENU_OFFSET, + fallbackPointer: pointer, + }) } function getChatSelectionFromContainer( @@ -129,22 +108,25 @@ function getChatSelectionFromContainer( function ChatSelectionMenu({ selection, onAdd, + popoverRef, }: { selection: ChatSelectionState | null onAdd: () => void + popoverRef: { current: HTMLButtonElement | null } }) { const t = useTranslation() if (!selection) return null return ( ) @@ -299,6 +281,7 @@ function SelectableChatMessage({ children: ReactNode }) { const rootRef = useRef(null) + const selectionMenuRef = useRef(null) const addReference = useWorkspaceChatContextStore((state) => state.addReference) const [selectionMenu, setSelectionMenu] = useState(null) const t = useTranslation() @@ -310,6 +293,16 @@ function SelectableChatMessage({ setSelectionMenu(null) }, [content, messageId]) + const dismissSelectionMenu = useCallback(() => { + setSelectionMenu(null) + }, []) + + useSelectionPopoverDismiss({ + active: Boolean(selectionMenu), + popoverRef: selectionMenuRef, + onDismiss: dismissSelectionMenu, + }) + const addCurrentSelectionToChat = useCallback(() => { if (!sessionId || !selectionMenu) return addReference(sessionId, { @@ -321,7 +314,7 @@ function SelectableChatMessage({ messageId, }) setSelectionMenu(null) - window.getSelection()?.removeAllRanges() + clearWindowSelection() }, [addReference, messageId, role, selectionMenu, sessionId, sourceName]) return ( @@ -335,7 +328,7 @@ function SelectableChatMessage({ }} > {children} - + ) } diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index d3a652ec..f5bfaf0c 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -144,13 +144,13 @@ async function selectWorkspaceCodeText( Object.assign(range, { getBoundingClientRect: () => ({ left: 120, - top: 40, + top: 100, right: 240, - bottom: 58, + bottom: 118, width: 120, height: 18, x: 120, - y: 40, + y: 100, toJSON: () => ({}), }), }) @@ -160,7 +160,7 @@ async function selectWorkspaceCodeText( selection?.addRange(range) await act(async () => { - fireEvent.mouseUp(code, { clientX: 180, clientY: 72 }) + fireEvent.mouseUp(code, { clientX: 180, clientY: 122 }) await Promise.resolve() }) await flushReactWork() @@ -1444,8 +1444,8 @@ describe('WorkspacePanel', () => { const addButtons = view.getAllByRole('button', { name: 'Add to chat' }) const floatingAddButton = addButtons[addButtons.length - 1]! - expect(floatingAddButton.style.left).toBe('180px') - expect(floatingAddButton.style.top).toBe('80px') + expect(floatingAddButton.style.left).toBe('101px') + expect(floatingAddButton.style.top).toBe('46px') fireEvent.keyDown(view.getByTestId('workspace-code').parentElement?.parentElement ?? view.getByTestId('workspace-code'), { key: 'Escape', @@ -1454,6 +1454,61 @@ describe('WorkspacePanel', () => { expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1) }) + it('dismisses the selected-code action when clicking outside the popover', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-selection-dismiss': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-selection-dismiss': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-selection-dismiss': [{ + id: 'file:src/App.ts', + path: 'src/App.ts', + kind: 'file', + title: 'App.ts', + language: 'text', + content: 'const title = "Todo"\nexport default title', + state: 'ok', + size: 42, + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-selection-dismiss': 'file:src/App.ts', + }, + })) + + const view = await renderPanel('session-selection-dismiss') + + await selectWorkspaceCodeText(view, 1, 'const title = "Todo"', 1, 'const title = "Todo"') + expect(view.getAllByRole('button', { name: 'Add to chat' })).toHaveLength(2) + + await act(async () => { + fireEvent.pointerDown(document.body) + await Promise.resolve() + }) + await flushReactWork() + + expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1) + expect(window.getSelection()?.toString()).toBe('') + }) + it('adds selected markdown text from a preview to the chat context', async () => { await setWorkspaceState((state) => ({ ...state, diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx index 1adfa216..3bd6c2b6 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react' +import { MessageCircle } from 'lucide-react' import { Highlight } from 'prism-react-renderer' import type { WorkspaceChangedFile, @@ -18,6 +19,7 @@ import { useChatStore } from '../../stores/chatStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { useUIStore } from '../../stores/uiStore' import { copyTextToClipboard } from '../chat/clipboard' +import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { getFileExtension, @@ -84,7 +86,9 @@ const FILE_STATUS_META: Record = {} const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = [] const EMPTY_EXPANDED_PATHS: string[] = [] -const SELECTION_MENU_OFFSET = 8 +const SELECTION_MENU_OFFSET = 10 +const SELECTION_MENU_WIDTH = 158 +const SELECTION_MENU_HEIGHT = 44 const FILE_BADGE_META: Record = { ts: { label: 'TS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, tsx: { label: 'TSX', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, @@ -239,37 +243,13 @@ function getLineNumberFromNode(node: Node | null, root: HTMLElement) { return Number.isFinite(line) ? line : undefined } -function clampValue(value: number, min: number, max: number) { - return Math.max(min, Math.min(value, max)) -} - function getSelectionPosition(range: Range, root: HTMLElement, pointer?: SelectionPointer) { - const rect = typeof range.getBoundingClientRect === 'function' - ? range.getBoundingClientRect() - : null - const rootRect = root.getBoundingClientRect() - const pointerInsideRoot = pointer - && pointer.clientX >= rootRect.left - && pointer.clientX <= rootRect.right - && pointer.clientY >= rootRect.top - && pointer.clientY <= rootRect.bottom - const fallbackX = rect && rect.width > 0 - ? rect.left + rect.width / 2 - : rect?.left ?? rootRect.left + 24 - const fallbackY = rect - ? rect.bottom + SELECTION_MENU_OFFSET - : rootRect.top + 24 - const unclampedX = pointerInsideRoot ? pointer.clientX : fallbackX - const unclampedY = pointerInsideRoot ? pointer.clientY + SELECTION_MENU_OFFSET : fallbackY - const minX = Math.max(12, rootRect.left + 8) - const maxX = Math.max(minX, Math.min(window.innerWidth - 160, rootRect.right - 136)) - const minY = Math.max(12, rootRect.top + 8) - const maxY = Math.max(minY, Math.min(window.innerHeight - 48, rootRect.bottom - 40)) - - return { - x: clampValue(unclampedX, minX, maxX), - y: clampValue(unclampedY, minY, maxY), - } + return getSelectionPopoverPosition(range, root, { + menuWidth: SELECTION_MENU_WIDTH, + menuHeight: SELECTION_MENU_HEIGHT, + offset: SELECTION_MENU_OFFSET, + fallbackPointer: pointer, + }) } function getTextSelectionFromContainer( @@ -321,22 +301,25 @@ function getLineRangeForText(value: string, text: string) { function FloatingSelectionMenu({ selection, onAdd, + popoverRef, }: { selection: FloatingSelectionMenuState | null onAdd: () => void + popoverRef: { current: HTMLButtonElement | null } }) { const t = useTranslation() if (!selection) return null return ( ) @@ -452,6 +435,7 @@ function CodeSurface({ }) { const t = useTranslation() const surfaceRef = useRef(null) + const selectionMenuRef = useRef(null) const [commentLine, setCommentLine] = useState(null) const [commentDraft, setCommentDraft] = useState('') const [showAllLines, setShowAllLines] = useState(false) @@ -469,6 +453,16 @@ function CodeSurface({ setSelectionMenu(null) }, [language, value]) + const dismissSelectionMenu = useCallback(() => { + setSelectionMenu(null) + }, []) + + useSelectionPopoverDismiss({ + active: Boolean(selectionMenu), + popoverRef: selectionMenuRef, + onDismiss: dismissSelectionMenu, + }) + const submitLineComment = () => { if (!commentLine || !commentDraft.trim()) return onAddLineComment(commentLine, commentDraft.trim(), activeQuote) @@ -497,7 +491,7 @@ function CodeSurface({ endLine: selectionMenu.endLine, }) setSelectionMenu(null) - window.getSelection()?.removeAllRanges() + clearWindowSelection() } const renderLineCommentEditor = (lineNumber: number) => { @@ -650,7 +644,7 @@ function CodeSurface({ )} - + ) } @@ -663,12 +657,23 @@ function MarkdownSurface({ onAddSelection: (selection: WorkspaceTextSelection) => void }) { const surfaceRef = useRef(null) + const selectionMenuRef = useRef(null) const [selectionMenu, setSelectionMenu] = useState(null) useEffect(() => { setSelectionMenu(null) }, [value]) + const dismissSelectionMenu = useCallback(() => { + setSelectionMenu(null) + }, []) + + useSelectionPopoverDismiss({ + active: Boolean(selectionMenu), + popoverRef: selectionMenuRef, + onDismiss: dismissSelectionMenu, + }) + const handleSelectionMouseUp = (event: MouseEvent) => { setSelectionMenu(getTextSelectionFromContainer( surfaceRef.current, @@ -685,7 +690,7 @@ function MarkdownSurface({ endLine: selectionMenu.endLine, }) setSelectionMenu(null) - window.getSelection()?.removeAllRanges() + clearWindowSelection() } return ( @@ -704,7 +709,7 @@ function MarkdownSurface({ className="workspace-markdown-preview prose-p:text-[14px] prose-p:leading-7 prose-h1:text-[24px] prose-h2:text-[18px] prose-h3:text-[15px] prose-code:text-[12px] prose-pre:my-4" /> - + ) } diff --git a/desktop/src/hooks/useSelectionPopoverDismiss.ts b/desktop/src/hooks/useSelectionPopoverDismiss.ts new file mode 100644 index 00000000..a7c17acf --- /dev/null +++ b/desktop/src/hooks/useSelectionPopoverDismiss.ts @@ -0,0 +1,93 @@ +import { useEffect } from 'react' + +type ElementRef = { + current: HTMLElement | null +} + +const VIEWPORT_MARGIN = 12 + +function clampValue(value: number, min: number, max: number) { + return Math.max(min, Math.min(value, max)) +} + +export function clearWindowSelection() { + window.getSelection()?.removeAllRanges() +} + +export function getSelectionPopoverPosition( + range: Range, + root: HTMLElement, + { + menuWidth, + menuHeight, + offset, + fallbackPointer, + }: { + menuWidth: number + menuHeight: number + offset: number + fallbackPointer?: { clientX: number; clientY: number } + }, +) { + const rect = typeof range.getBoundingClientRect === 'function' + ? range.getBoundingClientRect() + : null + 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 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), + } +} + +export function useSelectionPopoverDismiss({ + active, + popoverRef, + onDismiss, +}: { + active: boolean + popoverRef: ElementRef + onDismiss: () => void +}) { + useEffect(() => { + if (!active) return + + const handlePointerDown = (event: PointerEvent) => { + const popover = popoverRef.current + const target = event.target + if (popover && target instanceof Node && popover.contains(target)) { + return + } + + onDismiss() + clearWindowSelection() + } + + document.addEventListener('pointerdown', handlePointerDown, true) + return () => document.removeEventListener('pointerdown', handlePointerDown, true) + }, [active, onDismiss, popoverRef]) +} diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 750cfd9f..03ded6da 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -122,7 +122,7 @@ export const zh: Record = { 'workspace.showAllLoadedLines': '显示全部已加载行', 'workspace.collapsePreview': '收起预览', 'workspace.addToChat': '添加到聊天', - 'workspace.addSelectionToChat': '添加至对话', + 'workspace.addSelectionToChat': '添加到对话', 'workspace.copyPath': '复制路径', 'workspace.pathCopied': '路径已复制。', 'workspace.localComment': '本地评论', @@ -907,7 +907,7 @@ export const zh: Record = { 'chat.dropFilesHint': '会以文件路径附加到当前输入。', 'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用', 'chat.contextReferencesOnly': '已添加 {count} 个引用', - 'chat.addSelectionToChat': '添加至对话', + 'chat.addSelectionToChat': '添加到对话', 'chat.userMessageReference': '用户消息', 'chat.assistantMessageReference': 'AI 回复', 'chat.slashCommands': '斜杠命令',