From e9e4f3e1e5dcd9515328810ac8159a9cd4793f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 1 Jun 2026 16:48:15 +0800 Subject: [PATCH] fix: dismiss chat selection bubble on scroll (#672) The chat add-to-session popover is anchored to viewport coordinates, so it can float over unrelated messages after the transcript scrolls. The shared selection-popover dismissal hook now treats captured scroll as a dismissal signal, matching the existing outside-click behavior and clearing the stale text selection.\n\nConstraint: Desktop selection popovers are owned by message/workspace child components, not by the transcript scroll container.\nRejected: Thread scroll state through every selectable message | would re-render message rows during scroll for a global dismissal concern.\nConfidence: high\nScope-risk: narrow\nDirective: Keep selection popover dismissal centralized unless a future menu needs scroll persistence explicitly.\nTested: bun run test -- src/components/chat/MessageList.test.tsx -t "dismisses the selected-message action when the message list scrolls"\nTested: bun run test -- src/components/chat/MessageList.test.tsx\nTested: bun run check:desktop --- .../src/components/chat/MessageList.test.tsx | 30 +++++++++++++++++++ .../src/hooks/useSelectionPopoverDismiss.ts | 18 +++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 5823c4fc..da15625e 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -1715,6 +1715,36 @@ describe('MessageList nested tool calls', () => { expect(window.getSelection()?.toString()).toBe('') }) + it('dismisses the selected-message action when the message list scrolls', async () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [{ + id: 'assistant-1', + type: 'assistant_text', + content: 'Scrolling should clear this selected reply.', + timestamp: 1, + }], + }), + }, + }) + + const { container } = render() + + const assistantText = screen.getByText(/Scrolling should clear/) + await selectMessageText(assistantText, 'selected reply') + expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy() + + const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement + await act(async () => { + fireEvent.scroll(scroller) + 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: { diff --git a/desktop/src/hooks/useSelectionPopoverDismiss.ts b/desktop/src/hooks/useSelectionPopoverDismiss.ts index a7c17acf..505efe42 100644 --- a/desktop/src/hooks/useSelectionPopoverDismiss.ts +++ b/desktop/src/hooks/useSelectionPopoverDismiss.ts @@ -76,6 +76,11 @@ export function useSelectionPopoverDismiss({ useEffect(() => { if (!active) return + const dismiss = () => { + onDismiss() + clearWindowSelection() + } + const handlePointerDown = (event: PointerEvent) => { const popover = popoverRef.current const target = event.target @@ -83,11 +88,18 @@ export function useSelectionPopoverDismiss({ return } - onDismiss() - clearWindowSelection() + dismiss() + } + + const handleScroll = () => { + dismiss() } document.addEventListener('pointerdown', handlePointerDown, true) - return () => document.removeEventListener('pointerdown', handlePointerDown, true) + document.addEventListener('scroll', handleScroll, true) + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true) + document.removeEventListener('scroll', handleScroll, true) + } }, [active, onDismiss, popoverRef]) }