From 189cb245c204f32466a0d21d978a5a8e4d90c251 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 24 Jun 2026 11:24:42 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AB=A5=20fix:=20Hide=20Quote=20Popup=20Wh?= =?UTF-8?q?en=20Selection=20Collapses=20Silently=20(#13936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Add to chat" popup lingered over an empty caret after a selection collapsed through a path that fires no mouse/key event — most often a streaming markdown re-render replacing the selected text node. The selection state only updated on mouseup/dblclick/keyup/scroll/resize, so a silent collapse left the button stranded ("showing up with nothing selected"). Add a `selectionchange` listener that hides the popup the instant the selection collapses or empties. It only hides, never shows, so an in-progress drag-select still won't flicker the popup. Adds an e2e that collapses the selection without a mouse event and asserts the popup disappears. --- .../src/components/Chat/Input/QuoteButton.tsx | 13 ++++++++++++ e2e/specs/mock/quotes.spec.ts | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+) 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, }) => {