🖱️ fix: Summon Quote Popup on Double-Click Word Selection (#13923)

* 🖱️ fix: Summon Quote Popup on Double-Click Word Selection

Chromium commits a double-click word selection on the `dblclick` event, after `mouseup` has already read a still-collapsed range, so the "Add to chat" popup never appeared for double-click selections. Listen for `dblclick` in addition to `mouseup`/`keyup`.

Adds an e2e covering a native double-click word selection (measured-coordinate dblclick exercises the real browser path, unlike the programmatic-Range helper).

* 🎯 test: Target Reply Text Node in Double-Click Quote E2E

Walk to the text node containing the needle (not the first text node in .message-render, which may be a select-none screen-reader/model-label header) and measure the needle's first character, so the native double-click lands on the reply word rather than metadata.
This commit is contained in:
Danny Avila 2026-06-23 15:52:34 -04:00 committed by GitHub
parent 1eb460eb03
commit f616a58fb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 64 additions and 0 deletions

View file

@ -92,12 +92,16 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
const clearSelection = () => 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('scroll', clearSelection, true);
window.addEventListener('resize', clearSelection);
return () => {
document.removeEventListener('mouseup', updateSelection);
document.removeEventListener('dblclick', updateSelection);
document.removeEventListener('keyup', updateSelection);
document.removeEventListener('scroll', clearSelection, true);
window.removeEventListener('resize', clearSelection);

View file

@ -47,6 +47,40 @@ async function selectMessageText(page: Page, needle: string) {
}, needle);
}
/**
* Double-click the first word of `needle` inside the most recent message
* containing it, using native mouse events at that word's measured coordinates.
* Unlike `selectMessageText` (a programmatic Range), this exercises the
* browser's own double-click word selection the path the `dblclick` listener
* guards. Measuring the `needle` text node itself (not the first text node in
* `.message-render`, which may be a `select-none` screen-reader/model-label
* header) keeps the click on the actual reply word, not metadata or whitespace.
*/
async function doubleClickWord(page: Page, needle: string) {
const point = await page.evaluate((text) => {
const renders = Array.from(document.querySelectorAll('.message-render'));
const host = [...renders].reverse().find((el) => (el.textContent ?? '').includes(text));
if (!host) {
throw new Error(`No message contains: ${text}`);
}
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node && !(node.nodeValue ?? '').includes(text)) {
node = walker.nextNode();
}
if (!node) {
throw new Error(`No text node contains: ${text}`);
}
const index = (node.nodeValue ?? '').indexOf(text);
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + 1);
const r = range.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
}, needle);
await page.mouse.dblclick(point.x, point.y);
}
const addToChat = (page: Page) => page.getByTestId('add-to-chat-button');
const pendingChips = (page: Page) => page.getByTestId('pending-quote-chips');
const messageQuotes = (page: Page) => messagesView(page).getByTestId('message-quotes');
@ -108,6 +142,32 @@ test.describe('quote references', () => {
await expect(messageQuotes(page)).toContainText(MOCK_REPLY_TEXT);
});
test('summons the popup from a native double-click word selection', 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 dblclick');
expect(response.ok()).toBeTruthy();
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
// A real double-click selects the word under the cursor. Chromium commits
// that selection on `dblclick`, AFTER `mouseup` fires, so only a `dblclick`
// listener catches it — a programmatic Range (the other tests) would bypass
// this path entirely. Retried as a unit: auto-scroll can clear a fresh
// selection, which races the scripted double-click.
await expect(async () => {
await doubleClickWord(page, MOCK_REPLY_TEXT);
const button = addToChat(page);
await expect(button).toBeVisible({ timeout: 3000 });
await button.click();
await expect(pendingChips(page)).toHaveAttribute('data-quote-count', '1');
}).toPass({ timeout: 30000 });
// The quoted excerpt is a word from the reply, not empty.
await expect(pendingChips(page)).toContainText(/E2E|mock|reply/i);
});
test('collapses multiple selections into one chip with a hover popup, and removes one', async ({
page,
}) => {