diff --git a/client/src/components/Chat/Input/QuoteButton.tsx b/client/src/components/Chat/Input/QuoteButton.tsx index 073d96aeeb..01a391e192 100644 --- a/client/src/components/Chat/Input/QuoteButton.tsx +++ b/client/src/components/Chat/Input/QuoteButton.tsx @@ -90,12 +90,24 @@ function QuoteButton({ conversationId }: { conversationId: string }) { setPos(null); }; const clearSelection = () => setSelection(null); + /** Hide the popup the instant the selection collapses or empties, including + * paths that fire no mouse/key event — e.g. a streaming markdown re-render + * replacing the selected text node, which would otherwise leave the button + * stranded over a now-collapsed caret. Only hides here; showing stays gated + * on mouseup/dblclick/keyup so an in-progress drag never flickers it. */ + const handleSelectionChange = () => { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { + setSelection(null); + } + }; document.addEventListener('mouseup', updateSelection); /** Chromium commits a double-click word selection on `dblclick`, after * `mouseup` has already read a still-collapsed range, so listen here too. */ document.addEventListener('dblclick', updateSelection); document.addEventListener('keyup', updateSelection); + document.addEventListener('selectionchange', handleSelectionChange); document.addEventListener('scroll', clearSelection, true); window.addEventListener('resize', clearSelection); @@ -103,6 +115,7 @@ function QuoteButton({ conversationId }: { conversationId: string }) { document.removeEventListener('mouseup', updateSelection); document.removeEventListener('dblclick', updateSelection); document.removeEventListener('keyup', updateSelection); + document.removeEventListener('selectionchange', handleSelectionChange); document.removeEventListener('scroll', clearSelection, true); window.removeEventListener('resize', clearSelection); }; diff --git a/e2e/specs/mock/quotes.spec.ts b/e2e/specs/mock/quotes.spec.ts index 7271ae8e54..104965af7d 100644 --- a/e2e/specs/mock/quotes.spec.ts +++ b/e2e/specs/mock/quotes.spec.ts @@ -168,6 +168,27 @@ test.describe('quote references', () => { await expect(pendingChips(page)).toContainText(/E2E|mock|reply/i); }); + test('hides the popup when the selection collapses without a mouse event', async ({ page }) => { + test.setTimeout(120000); + await page.goto(NEW_CHAT_PATH, { timeout: 10000 }); + await selectMockEndpoint(page, MOCK_ENDPOINTS[0]); + + const response = await sendMessage(page, 'seed for collapse'); + expect(response.ok()).toBeTruthy(); + await expect(mockReply(page)).toBeVisible({ timeout: 20000 }); + + await expect(async () => { + await doubleClickWord(page, MOCK_REPLY_TEXT); + await expect(addToChat(page)).toBeVisible({ timeout: 3000 }); + }).toPass({ timeout: 30000 }); + + // Collapse the selection the way a streaming markdown re-render does — + // dropping the selected text node fires only `selectionchange`, not a + // mouse/key event. The popup must not linger over the now-empty caret. + await page.evaluate(() => window.getSelection()?.collapseToEnd()); + await expect(addToChat(page)).toBeHidden({ timeout: 5000 }); + }); + test('collapses multiple selections into one chip with a hover popup, and removes one', async ({ page, }) => {