🫥 fix: Hide Quote Popup When Selection Collapses Silently (#13936)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

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.
This commit is contained in:
Danny Avila 2026-06-24 11:24:42 -04:00 committed by GitHub
parent 82662443e5
commit 189cb245c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 34 additions and 0 deletions

View file

@ -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);
};

View file

@ -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,
}) => {