From dee8088e049f1b1c2be2de492bf0ceb0a00caad9 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Mon, 17 Aug 2026 23:26:10 +0800 Subject: [PATCH 1/8] feat(ui): quote selected answers for follow-up questions --- src/chrome/src/ui/selection-quote.js | 23 +++++++ src/chrome/src/ui/sidepanel.html | 2 + src/chrome/src/ui/sidepanel.js | 94 +++++++++++++++++++++++++++ src/chrome/styles/sidepanel.css | 38 +++++++++++ src/firefox/src/ui/selection-quote.js | 23 +++++++ src/firefox/src/ui/sidepanel.html | 2 + src/firefox/src/ui/sidepanel.js | 94 +++++++++++++++++++++++++++ src/firefox/styles/sidepanel.css | 38 +++++++++++ test/run.js | 37 +++++++++++ 9 files changed, 351 insertions(+) create mode 100644 src/chrome/src/ui/selection-quote.js create mode 100644 src/firefox/src/ui/selection-quote.js diff --git a/src/chrome/src/ui/selection-quote.js b/src/chrome/src/ui/selection-quote.js new file mode 100644 index 000000000..e0a38cf97 --- /dev/null +++ b/src/chrome/src/ui/selection-quote.js @@ -0,0 +1,23 @@ +function normalizedSelectionText(text) { + return String(text == null ? '' : text).replace(/\r\n?/g, '\n').trim(); +} + +export function buildSelectionQuote(text) { + const selection = normalizedSelectionText(text); + if (!selection) return ''; + return `${selection.split('\n').map((line) => `> ${line}`).join('\n')}\n\n`; +} + +export function buildSelectionComposerDraft(selectionText, draft = '') { + const quote = buildSelectionQuote(selectionText); + const existingDraft = String(draft == null ? '' : draft); + return quote ? `${quote}${existingDraft.trim() ? existingDraft : ''}` : existingDraft; +} + +export function selectionIsQuoteable({ startTextElement, endTextElement, text } = {}) { + return Boolean( + startTextElement + && startTextElement === endTextElement + && normalizedSelectionText(text), + ); +} diff --git a/src/chrome/src/ui/sidepanel.html b/src/chrome/src/ui/sidepanel.html index 2025e6eb5..194b7c06f 100644 --- a/src/chrome/src/ui/sidepanel.html +++ b/src/chrome/src/ui/sidepanel.html @@ -356,6 +356,8 @@

Start a new c + +
diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index f9e6ee0a3..4f5523e98 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -29,6 +29,8 @@ import { runUiUnavailableBeforeSeq } from '../run-ui-journal.js'; import { formatErrorMessage } from '../error-format.js'; import { buildMessageInfoPills } from '../message-info.js'; import { escapeHtml } from './utils.js'; +import { buildSelectionComposerDraft, selectionIsQuoteable } from './selection-quote.js'; +import { getSelectionShortcutLocalization } from '../selection-shortcut-i18n.js'; import { isBackgroundConnectionError, runDetachedWithReconnect, @@ -535,6 +537,7 @@ const selectionScopeBannerEl = document.getElementById('selection-scope-banner') const selectionScopeTitleEl = document.getElementById('selection-scope-title'); const selectionScopeDescriptionEl = document.getElementById('selection-scope-description'); const selectionScopeNewConversationBtn = document.getElementById('selection-scope-new-conversation'); +const selectionAskActionEl = document.getElementById('selection-ask-action'); const historyBtn = document.getElementById('btn-history'); const expandBtn = document.getElementById('btn-expand'); const settingsBtn = document.getElementById('btn-settings'); @@ -589,6 +592,7 @@ const ASK_PLACEHOLDER_KEYS = [ 'sp.input.placeholder_tip.record', ]; const PERMISSION_REMINDER_PLACEHOLDER_KEY = 'sp.input.placeholder_tip.skip_permissions'; +let pendingAnswerSelection = null; const SLASH_COMMANDS = [ { value: '/help', usage: '/help', descriptionKey: 'sp.slash.help', action: 'show', outOfBand: true }, { @@ -10678,6 +10682,81 @@ function refreshOpenMessageInfoRows() { }); } +function assistantTextElementForSelectionNode(node) { + const element = node?.nodeType === 1 ? node : node?.parentElement; + return element?.closest?.('.message.assistant .message-text') || null; +} + +function selectedAssistantAnswer() { + const selection = window.getSelection?.(); + if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null; + const range = selection.getRangeAt(0); + const startTextElement = assistantTextElementForSelectionNode(range.startContainer); + const endTextElement = assistantTextElementForSelectionNode(range.endContainer); + const text = selection.toString(); + if (!selectionIsQuoteable({ startTextElement, endTextElement, text })) return null; + return { range, text }; +} + +function dismissSelectionAskAction() { + pendingAnswerSelection = null; + selectionAskActionEl?.classList.add('hidden'); +} + +function positionSelectionAskAction(range) { + if (!selectionAskActionEl || !range) return; + const rect = range.getBoundingClientRect(); + if (!rect.width && !rect.height) { + dismissSelectionAskAction(); + return; + } + const gap = 6; + const actionRect = selectionAskActionEl.getBoundingClientRect(); + const left = Math.min( + Math.max(8, rect.left), + Math.max(8, window.innerWidth - actionRect.width - 8), + ); + const belowTop = rect.bottom + gap; + const top = belowTop + actionRect.height <= window.innerHeight - 8 + ? belowTop + : Math.max(8, rect.top - actionRect.height - gap); + selectionAskActionEl.style.left = `${left}px`; + selectionAskActionEl.style.top = `${top}px`; +} + +function refreshSelectionAskAction() { + const selected = selectedAssistantAnswer(); + if (!selected || !selectionAskActionEl) { + dismissSelectionAskAction(); + return; + } + pendingAnswerSelection = selected; + const label = getSelectionShortcutLocalization(getLocale()).askQuestion || 'Ask WebBrain a question'; + selectionAskActionEl.textContent = label; + selectionAskActionEl.title = label; + selectionAskActionEl.setAttribute('aria-label', label); + selectionAskActionEl.classList.remove('hidden'); + requestAnimationFrame(() => { + if (pendingAnswerSelection === selected) positionSelectionAskAction(selected.range); + }); +} + +function askAboutSelectedAnswer() { + const selection = pendingAnswerSelection; + if (!selection) return; + const nextDraft = buildSelectionComposerDraft(selection.text, inputEl.value); + if (nextDraft === inputEl.value) { + dismissSelectionAskAction(); + return; + } + inputEl.value = nextDraft; + dismissSelectionAskAction(); + window.getSelection?.()?.removeAllRanges(); + handleInput(); + inputEl.focus(); + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); +} + function addMessage(role, content, options = {}) { const msgEl = document.createElement('div'); msgEl.className = `message ${role}`; @@ -12683,6 +12762,21 @@ if (attachBtn && fileAttachInput) { // --- Event Listeners --- +if (selectionAskActionEl) { + selectionAskActionEl.addEventListener('mousedown', (event) => event.preventDefault()); + selectionAskActionEl.addEventListener('click', (event) => { + event.stopPropagation(); + askAboutSelectedAnswer(); + }); + document.addEventListener('selectionchange', refreshSelectionAskAction); + document.addEventListener('pointerdown', (event) => { + if (!selectionAskActionEl.contains(event.target)) dismissSelectionAskAction(); + }); + chatContainerEl?.addEventListener('scroll', dismissSelectionAskAction, { passive: true }); + window.addEventListener('resize', dismissSelectionAskAction); + document.addEventListener('wb-locale-changed', refreshSelectionAskAction); +} + sendBtn.addEventListener('click', sendMessage); document.addEventListener('keydown', handleGlobalKeydown, true); diff --git a/src/chrome/styles/sidepanel.css b/src/chrome/styles/sidepanel.css index 8079b983b..44e681bbd 100644 --- a/src/chrome/styles/sidepanel.css +++ b/src/chrome/styles/sidepanel.css @@ -2337,6 +2337,44 @@ body { opacity: 0.48; } +.selection-ask-action { + position: fixed; + z-index: 30; + max-width: calc(100vw - 16px); + padding: 6px 10px; + border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); + border-radius: 999px; + background: var(--bg-secondary); + box-shadow: 0 5px 16px color-mix(in srgb, var(--text-primary) 18%, transparent); + color: var(--text-primary); + cursor: pointer; + font: inherit; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.selection-ask-action.hidden { + display: none; +} + +.selection-ask-action:hover { + border-color: var(--accent); + background: var(--accent-dim); +} + +.selection-ask-action:focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent) 34%, transparent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .selection-ask-action { + transition: none; + } +} + /* Context-aware recommendations — inline centered pill row in the chat body */ diff --git a/src/firefox/src/ui/selection-quote.js b/src/firefox/src/ui/selection-quote.js new file mode 100644 index 000000000..e0a38cf97 --- /dev/null +++ b/src/firefox/src/ui/selection-quote.js @@ -0,0 +1,23 @@ +function normalizedSelectionText(text) { + return String(text == null ? '' : text).replace(/\r\n?/g, '\n').trim(); +} + +export function buildSelectionQuote(text) { + const selection = normalizedSelectionText(text); + if (!selection) return ''; + return `${selection.split('\n').map((line) => `> ${line}`).join('\n')}\n\n`; +} + +export function buildSelectionComposerDraft(selectionText, draft = '') { + const quote = buildSelectionQuote(selectionText); + const existingDraft = String(draft == null ? '' : draft); + return quote ? `${quote}${existingDraft.trim() ? existingDraft : ''}` : existingDraft; +} + +export function selectionIsQuoteable({ startTextElement, endTextElement, text } = {}) { + return Boolean( + startTextElement + && startTextElement === endTextElement + && normalizedSelectionText(text), + ); +} diff --git a/src/firefox/src/ui/sidepanel.html b/src/firefox/src/ui/sidepanel.html index 8fa8fba48..d1bd79aba 100644 --- a/src/firefox/src/ui/sidepanel.html +++ b/src/firefox/src/ui/sidepanel.html @@ -311,6 +311,8 @@

Start a new c

+ +
diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 854dbe6ec..f38634069 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -29,6 +29,8 @@ import { runUiUnavailableBeforeSeq } from '../run-ui-journal.js'; import { formatErrorMessage } from '../error-format.js'; import { buildMessageInfoPills } from '../message-info.js'; import { escapeHtml } from './utils.js'; +import { buildSelectionComposerDraft, selectionIsQuoteable } from './selection-quote.js'; +import { getSelectionShortcutLocalization } from '../selection-shortcut-i18n.js'; import { isBackgroundConnectionError, runDetachedWithReconnect, @@ -414,6 +416,7 @@ const selectionScopeBannerEl = document.getElementById('selection-scope-banner') const selectionScopeTitleEl = document.getElementById('selection-scope-title'); const selectionScopeDescriptionEl = document.getElementById('selection-scope-description'); const selectionScopeNewConversationBtn = document.getElementById('selection-scope-new-conversation'); +const selectionAskActionEl = document.getElementById('selection-ask-action'); const historyBtn = document.getElementById('btn-history'); const expandBtn = document.getElementById('btn-expand'); const settingsBtn = document.getElementById('btn-settings'); @@ -465,6 +468,7 @@ const ASK_PLACEHOLDER_KEYS = [ 'sp.input.placeholder_tip.help', ]; const PERMISSION_REMINDER_PLACEHOLDER_KEY = 'sp.input.placeholder_tip.skip_permissions'; +let pendingAnswerSelection = null; const SLASH_COMMANDS = [ { value: '/help', usage: '/help', descriptionKey: 'sp.slash.help', action: 'show', outOfBand: true }, { @@ -10299,6 +10303,81 @@ function refreshOpenMessageInfoRows() { }); } +function assistantTextElementForSelectionNode(node) { + const element = node?.nodeType === 1 ? node : node?.parentElement; + return element?.closest?.('.message.assistant .message-text') || null; +} + +function selectedAssistantAnswer() { + const selection = window.getSelection?.(); + if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null; + const range = selection.getRangeAt(0); + const startTextElement = assistantTextElementForSelectionNode(range.startContainer); + const endTextElement = assistantTextElementForSelectionNode(range.endContainer); + const text = selection.toString(); + if (!selectionIsQuoteable({ startTextElement, endTextElement, text })) return null; + return { range, text }; +} + +function dismissSelectionAskAction() { + pendingAnswerSelection = null; + selectionAskActionEl?.classList.add('hidden'); +} + +function positionSelectionAskAction(range) { + if (!selectionAskActionEl || !range) return; + const rect = range.getBoundingClientRect(); + if (!rect.width && !rect.height) { + dismissSelectionAskAction(); + return; + } + const gap = 6; + const actionRect = selectionAskActionEl.getBoundingClientRect(); + const left = Math.min( + Math.max(8, rect.left), + Math.max(8, window.innerWidth - actionRect.width - 8), + ); + const belowTop = rect.bottom + gap; + const top = belowTop + actionRect.height <= window.innerHeight - 8 + ? belowTop + : Math.max(8, rect.top - actionRect.height - gap); + selectionAskActionEl.style.left = `${left}px`; + selectionAskActionEl.style.top = `${top}px`; +} + +function refreshSelectionAskAction() { + const selected = selectedAssistantAnswer(); + if (!selected || !selectionAskActionEl) { + dismissSelectionAskAction(); + return; + } + pendingAnswerSelection = selected; + const label = getSelectionShortcutLocalization(getLocale()).askQuestion || 'Ask WebBrain a question'; + selectionAskActionEl.textContent = label; + selectionAskActionEl.title = label; + selectionAskActionEl.setAttribute('aria-label', label); + selectionAskActionEl.classList.remove('hidden'); + requestAnimationFrame(() => { + if (pendingAnswerSelection === selected) positionSelectionAskAction(selected.range); + }); +} + +function askAboutSelectedAnswer() { + const selection = pendingAnswerSelection; + if (!selection) return; + const nextDraft = buildSelectionComposerDraft(selection.text, inputEl.value); + if (nextDraft === inputEl.value) { + dismissSelectionAskAction(); + return; + } + inputEl.value = nextDraft; + dismissSelectionAskAction(); + window.getSelection?.()?.removeAllRanges(); + handleInput(); + inputEl.focus(); + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); +} + function addMessage(role, content, options = {}) { const msgEl = document.createElement('div'); msgEl.className = `message ${role}`; @@ -12168,6 +12247,21 @@ if (attachBtn && fileAttachInput) { // --- Event Listeners --- +if (selectionAskActionEl) { + selectionAskActionEl.addEventListener('mousedown', (event) => event.preventDefault()); + selectionAskActionEl.addEventListener('click', (event) => { + event.stopPropagation(); + askAboutSelectedAnswer(); + }); + document.addEventListener('selectionchange', refreshSelectionAskAction); + document.addEventListener('pointerdown', (event) => { + if (!selectionAskActionEl.contains(event.target)) dismissSelectionAskAction(); + }); + chatContainerEl?.addEventListener('scroll', dismissSelectionAskAction, { passive: true }); + window.addEventListener('resize', dismissSelectionAskAction); + document.addEventListener('wb-locale-changed', refreshSelectionAskAction); +} + sendBtn.addEventListener('click', sendMessage); document.addEventListener('keydown', handleGlobalKeydown, true); diff --git a/src/firefox/styles/sidepanel.css b/src/firefox/styles/sidepanel.css index f71ddc2de..fef2d73fe 100644 --- a/src/firefox/styles/sidepanel.css +++ b/src/firefox/styles/sidepanel.css @@ -2174,6 +2174,44 @@ body { opacity: 0.48; } +.selection-ask-action { + position: fixed; + z-index: 30; + max-width: calc(100vw - 16px); + padding: 6px 10px; + border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); + border-radius: 999px; + background: var(--bg-secondary); + box-shadow: 0 5px 16px color-mix(in srgb, var(--text-primary) 18%, transparent); + color: var(--text-primary); + cursor: pointer; + font: inherit; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.selection-ask-action.hidden { + display: none; +} + +.selection-ask-action:hover { + border-color: var(--accent); + background: var(--accent-dim); +} + +.selection-ask-action:focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent) 34%, transparent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .selection-ask-action { + transition: none; + } +} + /* Context-aware recommendations — inline centered pill row in the chat body */ diff --git a/test/run.js b/test/run.js index 2acb0ed09..acdd250d3 100644 --- a/test/run.js +++ b/test/run.js @@ -1124,6 +1124,21 @@ const { 'file://' + path.join(ROOT, 'src/firefox/src/agent/sheets-tools.js').replace(/\\/g, '/') ); +const { + buildSelectionQuote, + buildSelectionComposerDraft, + selectionIsQuoteable, +} = await import( + 'file://' + path.join(ROOT, 'src/chrome/src/ui/selection-quote.js').replace(/\\/g, '/') +); +const { + buildSelectionQuote: buildSelectionQuoteFx, + buildSelectionComposerDraft: buildSelectionComposerDraftFx, + selectionIsQuoteable: selectionIsQuoteableFx, +} = await import( + 'file://' + path.join(ROOT, 'src/firefox/src/ui/selection-quote.js').replace(/\\/g, '/') +); + // ──────────────────────────────────────────────────────────────────────── // Test framework (one function, no deps) // ──────────────────────────────────────────────────────────────────────── @@ -1131,6 +1146,28 @@ const { const tests = []; function test(name, fn) { tests.push({ name, fn }); } +console.log('\nselection quote'); + +test('buildSelectionQuote preserves multiline answer text as an editable quote', () => { + const selected = 'First line\n\n'; + const expected = '> First line\n> \n> \n\n'; + assert.equal(buildSelectionQuote(selected), expected); + assert.equal(buildSelectionQuoteFx(selected), expected, 'Firefox quote builder should match Chrome'); + assert.equal(buildSelectionComposerDraft('A detail', 'Why?'), '> A detail\n\nWhy?'); + assert.equal(buildSelectionComposerDraftFx('A detail', 'Why?'), '> A detail\n\nWhy?', 'Firefox draft builder should match Chrome'); +}); + +test('selectionIsQuoteable requires one non-empty assistant answer element', () => { + const answer = {}; + const otherAnswer = {}; + const valid = { startTextElement: answer, endTextElement: answer, text: 'A detail' }; + assert.equal(selectionIsQuoteable(valid), true); + assert.equal(selectionIsQuoteable({ ...valid, endTextElement: otherAnswer }), false); + assert.equal(selectionIsQuoteable({ ...valid, text: ' \n ' }), false); + assert.equal(selectionIsQuoteableFx(valid), true, 'Firefox eligibility should match Chrome'); + assert.equal(selectionIsQuoteableFx({ ...valid, endTextElement: otherAnswer }), false); +}); + console.log('\nscreenshot redaction'); test('selectRedactionRegions blurs password fields always', () => { From 43eded50f6d49091d2a84c254a246b8a2dd613e8 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Mon, 17 Aug 2026 23:31:52 +0800 Subject: [PATCH 2/8] fix(ui): position answer selection action before repaint --- src/chrome/src/ui/sidepanel.js | 1 + src/firefox/src/ui/sidepanel.js | 1 + 2 files changed, 2 insertions(+) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 4f5523e98..abec2e874 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -10736,6 +10736,7 @@ function refreshSelectionAskAction() { selectionAskActionEl.title = label; selectionAskActionEl.setAttribute('aria-label', label); selectionAskActionEl.classList.remove('hidden'); + positionSelectionAskAction(selected.range); requestAnimationFrame(() => { if (pendingAnswerSelection === selected) positionSelectionAskAction(selected.range); }); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index f38634069..e19ca9db7 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -10357,6 +10357,7 @@ function refreshSelectionAskAction() { selectionAskActionEl.title = label; selectionAskActionEl.setAttribute('aria-label', label); selectionAskActionEl.classList.remove('hidden'); + positionSelectionAskAction(selected.range); requestAnimationFrame(() => { if (pendingAnswerSelection === selected) positionSelectionAskAction(selected.range); }); From 8fc478a18e9528eea19f61a34df93f33125cc912 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Mon, 17 Aug 2026 23:38:52 +0800 Subject: [PATCH 3/8] fix(ui): preserve selection action lifecycle --- src/chrome/src/ui/selection-quote.js | 1 + src/chrome/src/ui/sidepanel.js | 6 +++++- src/chrome/styles/sidepanel.css | 6 ------ src/firefox/src/ui/selection-quote.js | 1 + src/firefox/src/ui/sidepanel.js | 6 +++++- src/firefox/styles/sidepanel.css | 6 ------ test/run.js | 15 +++++++++++++++ 7 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/chrome/src/ui/selection-quote.js b/src/chrome/src/ui/selection-quote.js index e0a38cf97..34eb948b9 100644 --- a/src/chrome/src/ui/selection-quote.js +++ b/src/chrome/src/ui/selection-quote.js @@ -15,6 +15,7 @@ export function buildSelectionComposerDraft(selectionText, draft = '') { } export function selectionIsQuoteable({ startTextElement, endTextElement, text } = {}) { + // A range spanning two bubbles has no unambiguous answer boundary. return Boolean( startTextElement && startTextElement === endTextElement diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index abec2e874..737d2ca93 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -2256,6 +2256,7 @@ function drainQueuedComposerMessageForCurrentTab() { } async function renderClearedConversationForTab(tabId) { + dismissSelectionAskAction(); setSelectionGroundedForTab(tabId, false); const clearResult = await clearCachedTabChat(tabId); if (!clearResult?.ok || clearResult?.skipped) { @@ -4212,6 +4213,7 @@ if (verboseBtn) { async function switchToTab(newTabId) { if (newTabId === currentTabId && renderedTabId === newTabId) { return; } + dismissSelectionAskAction(); if (newConversationConfirmationState && !sameTabId(newConversationConfirmationState.tabId, newTabId)) { settleNewConversationConfirmation(false, { restoreFocus: false }); @@ -10731,11 +10733,13 @@ function refreshSelectionAskAction() { return; } pendingAnswerSelection = selected; - const label = getSelectionShortcutLocalization(getLocale()).askQuestion || 'Ask WebBrain a question'; + const label = getSelectionShortcutLocalization(getLocale()).strings.askQuestion; selectionAskActionEl.textContent = label; selectionAskActionEl.title = label; selectionAskActionEl.setAttribute('aria-label', label); selectionAskActionEl.classList.remove('hidden'); + // selectionchange can be followed by a click before the next paint; correct + // the first position synchronously, then remeasure after layout settles. positionSelectionAskAction(selected.range); requestAnimationFrame(() => { if (pendingAnswerSelection === selected) positionSelectionAskAction(selected.range); diff --git a/src/chrome/styles/sidepanel.css b/src/chrome/styles/sidepanel.css index 44e681bbd..c9057e562 100644 --- a/src/chrome/styles/sidepanel.css +++ b/src/chrome/styles/sidepanel.css @@ -2369,12 +2369,6 @@ body { outline-offset: 2px; } -@media (prefers-reduced-motion: reduce) { - .selection-ask-action { - transition: none; - } -} - /* Context-aware recommendations — inline centered pill row in the chat body */ diff --git a/src/firefox/src/ui/selection-quote.js b/src/firefox/src/ui/selection-quote.js index e0a38cf97..34eb948b9 100644 --- a/src/firefox/src/ui/selection-quote.js +++ b/src/firefox/src/ui/selection-quote.js @@ -15,6 +15,7 @@ export function buildSelectionComposerDraft(selectionText, draft = '') { } export function selectionIsQuoteable({ startTextElement, endTextElement, text } = {}) { + // A range spanning two bubbles has no unambiguous answer boundary. return Boolean( startTextElement && startTextElement === endTextElement diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index e19ca9db7..f0099aede 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -2419,6 +2419,7 @@ function drainQueuedComposerMessageForCurrentTab() { } async function renderClearedConversationForTab(tabId) { + dismissSelectionAskAction(); setSelectionGroundedForTab(tabId, false); const clearResult = await clearCachedTabChat(tabId); if (!clearResult?.ok || clearResult?.skipped) { @@ -4058,6 +4059,7 @@ if (verboseBtn) { async function switchToTab(newTabId) { if (newTabId === currentTabId && renderedTabId === newTabId) { return; } + dismissSelectionAskAction(); if (newConversationConfirmationState && !sameTabId(newConversationConfirmationState.tabId, newTabId)) { settleNewConversationConfirmation(false, { restoreFocus: false }); @@ -10352,11 +10354,13 @@ function refreshSelectionAskAction() { return; } pendingAnswerSelection = selected; - const label = getSelectionShortcutLocalization(getLocale()).askQuestion || 'Ask WebBrain a question'; + const label = getSelectionShortcutLocalization(getLocale()).strings.askQuestion; selectionAskActionEl.textContent = label; selectionAskActionEl.title = label; selectionAskActionEl.setAttribute('aria-label', label); selectionAskActionEl.classList.remove('hidden'); + // selectionchange can be followed by a click before the next paint; correct + // the first position synchronously, then remeasure after layout settles. positionSelectionAskAction(selected.range); requestAnimationFrame(() => { if (pendingAnswerSelection === selected) positionSelectionAskAction(selected.range); diff --git a/src/firefox/styles/sidepanel.css b/src/firefox/styles/sidepanel.css index fef2d73fe..4957f56ea 100644 --- a/src/firefox/styles/sidepanel.css +++ b/src/firefox/styles/sidepanel.css @@ -2206,12 +2206,6 @@ body { outline-offset: 2px; } -@media (prefers-reduced-motion: reduce) { - .selection-ask-action { - transition: none; - } -} - /* Context-aware recommendations — inline centered pill row in the chat body */ diff --git a/test/run.js b/test/run.js index acdd250d3..5397704b2 100644 --- a/test/run.js +++ b/test/run.js @@ -1138,6 +1138,10 @@ const { } = await import( 'file://' + path.join(ROOT, 'src/firefox/src/ui/selection-quote.js').replace(/\\/g, '/') ); +const sidepanelSources = [ + fs.readFileSync(path.join(ROOT, 'src/chrome/src/ui/sidepanel.js'), 'utf8'), + fs.readFileSync(path.join(ROOT, 'src/firefox/src/ui/sidepanel.js'), 'utf8'), +]; // ──────────────────────────────────────────────────────────────────────── // Test framework (one function, no deps) @@ -1155,6 +1159,7 @@ test('buildSelectionQuote preserves multiline answer text as an editable quote', assert.equal(buildSelectionQuoteFx(selected), expected, 'Firefox quote builder should match Chrome'); assert.equal(buildSelectionComposerDraft('A detail', 'Why?'), '> A detail\n\nWhy?'); assert.equal(buildSelectionComposerDraftFx('A detail', 'Why?'), '> A detail\n\nWhy?', 'Firefox draft builder should match Chrome'); + assert.equal(buildSelectionComposerDraft('', 'draft'), 'draft'); }); test('selectionIsQuoteable requires one non-empty assistant answer element', () => { @@ -1168,6 +1173,16 @@ test('selectionIsQuoteable requires one non-empty assistant answer element', () assert.equal(selectionIsQuoteableFx({ ...valid, endTextElement: otherAnswer }), false); }); +test('selection answer action wiring covers show, dismiss, and tab/conversation changes in both sidepanels', () => { + for (const source of sidepanelSources) { + assert.match(source, /document\.addEventListener\('selectionchange', refreshSelectionAskAction\)/); + assert.match(source, /document\.addEventListener\('pointerdown', \(event\) => \{/); + assert.match(source, /selectionAskActionEl\.addEventListener\('click'/); + assert.match(source, /async function switchToTab\([\s\S]*?dismissSelectionAskAction\(\);/); + assert.match(source, /async function renderClearedConversationForTab\([\s\S]*?dismissSelectionAskAction\(\);/); + } +}); + console.log('\nscreenshot redaction'); test('selectRedactionRegions blurs password fields always', () => { From 8069c1115ceb0743f65fb636c698d08700601264 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Mon, 17 Aug 2026 23:48:30 +0800 Subject: [PATCH 4/8] fix(ui): keep answer selection action usable --- src/chrome/src/ui/sidepanel.js | 16 +++++++++++----- src/firefox/src/ui/sidepanel.js | 16 +++++++++++----- test/run.js | 2 ++ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 737d2ca93..524f0207d 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -10708,10 +10708,7 @@ function dismissSelectionAskAction() { function positionSelectionAskAction(range) { if (!selectionAskActionEl || !range) return; const rect = range.getBoundingClientRect(); - if (!rect.width && !rect.height) { - dismissSelectionAskAction(); - return; - } + if (!rect.width && !rect.height) return; const gap = 6; const actionRect = selectionAskActionEl.getBoundingClientRect(); const left = Math.min( @@ -10719,9 +10716,13 @@ function positionSelectionAskAction(range) { Math.max(8, window.innerWidth - actionRect.width - 8), ); const belowTop = rect.bottom + gap; - const top = belowTop + actionRect.height <= window.innerHeight - 8 + const preferredTop = belowTop + actionRect.height <= window.innerHeight - 8 ? belowTop : Math.max(8, rect.top - actionRect.height - gap); + const top = Math.min( + Math.max(8, window.innerHeight - actionRect.height - 8), + preferredTop, + ); selectionAskActionEl.style.left = `${left}px`; selectionAskActionEl.style.top = `${top}px`; } @@ -11761,6 +11762,11 @@ async function handleGlobalKeydown(e) { if (e.key === 'Escape') { const slashMenuOpen = !!slashCommandMenuEl && !slashCommandMenuEl.classList.contains('hidden'); if (slashMenuOpen) return; + if (selectionAskActionEl && !selectionAskActionEl.classList.contains('hidden')) { + e.preventDefault(); + dismissSelectionAskAction(); + return; + } if (isProcessing) { e.preventDefault(); abortRun(); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index f0099aede..19e29afe6 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -10329,10 +10329,7 @@ function dismissSelectionAskAction() { function positionSelectionAskAction(range) { if (!selectionAskActionEl || !range) return; const rect = range.getBoundingClientRect(); - if (!rect.width && !rect.height) { - dismissSelectionAskAction(); - return; - } + if (!rect.width && !rect.height) return; const gap = 6; const actionRect = selectionAskActionEl.getBoundingClientRect(); const left = Math.min( @@ -10340,9 +10337,13 @@ function positionSelectionAskAction(range) { Math.max(8, window.innerWidth - actionRect.width - 8), ); const belowTop = rect.bottom + gap; - const top = belowTop + actionRect.height <= window.innerHeight - 8 + const preferredTop = belowTop + actionRect.height <= window.innerHeight - 8 ? belowTop : Math.max(8, rect.top - actionRect.height - gap); + const top = Math.min( + Math.max(8, window.innerHeight - actionRect.height - 8), + preferredTop, + ); selectionAskActionEl.style.left = `${left}px`; selectionAskActionEl.style.top = `${top}px`; } @@ -11348,6 +11349,11 @@ async function handleGlobalKeydown(e) { if (e.isComposing) return; const slashMenuOpen = !!slashCommandMenuEl && !slashCommandMenuEl.classList.contains('hidden'); if (slashMenuOpen) return; + if (selectionAskActionEl && !selectionAskActionEl.classList.contains('hidden')) { + e.preventDefault(); + dismissSelectionAskAction(); + return; + } // Provider/language pickers close on Escape in bubble/target handlers; do not // abort the active run while those listboxes are open. const providerPickerOpen = !!providerPickerMenu && !providerPickerMenu.classList.contains('hidden'); diff --git a/test/run.js b/test/run.js index 5397704b2..a4f20806a 100644 --- a/test/run.js +++ b/test/run.js @@ -1178,6 +1178,8 @@ test('selection answer action wiring covers show, dismiss, and tab/conversation assert.match(source, /document\.addEventListener\('selectionchange', refreshSelectionAskAction\)/); assert.match(source, /document\.addEventListener\('pointerdown', \(event\) => \{/); assert.match(source, /selectionAskActionEl\.addEventListener\('click'/); + assert.match(source, /if \(!rect\.width && !rect\.height\) return;/); + assert.match(source, /selectionAskActionEl && !selectionAskActionEl\.classList\.contains\('hidden'\)[\s\S]*?dismissSelectionAskAction\(\);/); assert.match(source, /async function switchToTab\([\s\S]*?dismissSelectionAskAction\(\);/); assert.match(source, /async function renderClearedConversationForTab\([\s\S]*?dismissSelectionAskAction\(\);/); } From fb07e486dceb913685bb22d6b2337c54b7166a41 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Tue, 18 Aug 2026 00:00:03 +0800 Subject: [PATCH 5/8] fix(ui): clear stale selection actions on send --- src/chrome/src/ui/selection-quote.js | 3 ++- src/chrome/src/ui/sidepanel.js | 1 + src/firefox/src/ui/selection-quote.js | 3 ++- src/firefox/src/ui/sidepanel.js | 1 + test/run.js | 10 ++++++++-- 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/chrome/src/ui/selection-quote.js b/src/chrome/src/ui/selection-quote.js index 34eb948b9..a51e95877 100644 --- a/src/chrome/src/ui/selection-quote.js +++ b/src/chrome/src/ui/selection-quote.js @@ -11,7 +11,8 @@ export function buildSelectionQuote(text) { export function buildSelectionComposerDraft(selectionText, draft = '') { const quote = buildSelectionQuote(selectionText); const existingDraft = String(draft == null ? '' : draft); - return quote ? `${quote}${existingDraft.trim() ? existingDraft : ''}` : existingDraft; + if (!quote || existingDraft.startsWith(quote)) return existingDraft; + return `${quote}${existingDraft}`; } export function selectionIsQuoteable({ startTextElement, endTextElement, text } = {}) { diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 524f0207d..e9fce08cd 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -7860,6 +7860,7 @@ async function sendMessage(extraChatParams = {}) { ...(retryOptions ? { __retry: { ...retryOptions, mode: 'ask' } } : {}), }; } + dismissSelectionAskAction(); const retryOptions = extraChatParams?.__retry || null; const modeOverride = ['ask', 'act', 'dev'].includes(extraChatParams?.__mode) ? extraChatParams.__mode : null; const onContextMenuClaimRejected = typeof extraChatParams?.__onContextMenuClaimRejected === 'function' diff --git a/src/firefox/src/ui/selection-quote.js b/src/firefox/src/ui/selection-quote.js index 34eb948b9..a51e95877 100644 --- a/src/firefox/src/ui/selection-quote.js +++ b/src/firefox/src/ui/selection-quote.js @@ -11,7 +11,8 @@ export function buildSelectionQuote(text) { export function buildSelectionComposerDraft(selectionText, draft = '') { const quote = buildSelectionQuote(selectionText); const existingDraft = String(draft == null ? '' : draft); - return quote ? `${quote}${existingDraft.trim() ? existingDraft : ''}` : existingDraft; + if (!quote || existingDraft.startsWith(quote)) return existingDraft; + return `${quote}${existingDraft}`; } export function selectionIsQuoteable({ startTextElement, endTextElement, text } = {}) { diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 19e29afe6..83697e8fa 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -7535,6 +7535,7 @@ async function sendMessage(extraChatParams = {}) { ...(retryOptions ? { __retry: { ...retryOptions, mode: 'ask' } } : {}), }; } + dismissSelectionAskAction(); const retryOptions = extraChatParams?.__retry || null; const modeOverride = ['ask', 'act', 'dev'].includes(extraChatParams?.__mode) ? extraChatParams.__mode : null; const onContextMenuClaimRejected = typeof extraChatParams?.__onContextMenuClaimRejected === 'function' diff --git a/test/run.js b/test/run.js index a4f20806a..d40ad9f0e 100644 --- a/test/run.js +++ b/test/run.js @@ -1160,6 +1160,8 @@ test('buildSelectionQuote preserves multiline answer text as an editable quote', assert.equal(buildSelectionComposerDraft('A detail', 'Why?'), '> A detail\n\nWhy?'); assert.equal(buildSelectionComposerDraftFx('A detail', 'Why?'), '> A detail\n\nWhy?', 'Firefox draft builder should match Chrome'); assert.equal(buildSelectionComposerDraft('', 'draft'), 'draft'); + assert.equal(buildSelectionComposerDraft('A detail', '> A detail\n\nWhy?'), '> A detail\n\nWhy?'); + assert.equal(buildSelectionComposerDraft('A detail', ' '), '> A detail\n\n '); }); test('selectionIsQuoteable requires one non-empty assistant answer element', () => { @@ -1175,13 +1177,17 @@ test('selectionIsQuoteable requires one non-empty assistant answer element', () test('selection answer action wiring covers show, dismiss, and tab/conversation changes in both sidepanels', () => { for (const source of sidepanelSources) { + const switchToTabSource = source.slice(source.indexOf('async function switchToTab'), source.indexOf('async function switchToTab') + 320); + const clearConversationSource = source.slice(source.indexOf('async function renderClearedConversationForTab'), source.indexOf('async function renderClearedConversationForTab') + 240); + const sendMessageSource = source.slice(source.indexOf('async function sendMessage'), source.indexOf('async function sendMessage') + 500); assert.match(source, /document\.addEventListener\('selectionchange', refreshSelectionAskAction\)/); assert.match(source, /document\.addEventListener\('pointerdown', \(event\) => \{/); assert.match(source, /selectionAskActionEl\.addEventListener\('click'/); assert.match(source, /if \(!rect\.width && !rect\.height\) return;/); assert.match(source, /selectionAskActionEl && !selectionAskActionEl\.classList\.contains\('hidden'\)[\s\S]*?dismissSelectionAskAction\(\);/); - assert.match(source, /async function switchToTab\([\s\S]*?dismissSelectionAskAction\(\);/); - assert.match(source, /async function renderClearedConversationForTab\([\s\S]*?dismissSelectionAskAction\(\);/); + assert.match(switchToTabSource, /dismissSelectionAskAction\(\);/); + assert.match(clearConversationSource, /dismissSelectionAskAction\(\);/); + assert.match(sendMessageSource, /dismissSelectionAskAction\(\);/); } }); From 1948110d690f7e65ad7a8324a29f763936fbaea5 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Tue, 18 Aug 2026 00:20:05 +0800 Subject: [PATCH 6/8] fix(ui): harden selection action lifecycle --- src/chrome/src/ui/sidepanel.html | 2 +- src/chrome/src/ui/sidepanel.js | 49 +++++++++++++++++++++++-------- src/chrome/styles/sidepanel.css | 2 ++ src/firefox/src/ui/sidepanel.html | 2 +- src/firefox/src/ui/sidepanel.js | 49 +++++++++++++++++++++++-------- src/firefox/styles/sidepanel.css | 2 ++ test/run.js | 28 ++++++++++++++---- 7 files changed, 101 insertions(+), 33 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.html b/src/chrome/src/ui/sidepanel.html index 194b7c06f..bc1426a99 100644 --- a/src/chrome/src/ui/sidepanel.html +++ b/src/chrome/src/ui/sidepanel.html @@ -356,7 +356,7 @@

Start a new c

- +
diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index e9fce08cd..804037e2f 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -593,6 +593,9 @@ const ASK_PLACEHOLDER_KEYS = [ ]; const PERMISSION_REMINDER_PLACEHOLDER_KEY = 'sp.input.placeholder_tip.skip_permissions'; let pendingAnswerSelection = null; +let selectionAskActionRefreshFrame = null; +let selectionAskActionLocale = ''; +let selectionAskActionLabel = ''; const SLASH_COMMANDS = [ { value: '/help', usage: '/help', descriptionKey: 'sp.slash.help', action: 'show', outOfBand: true }, { @@ -10694,6 +10697,7 @@ function selectedAssistantAnswer() { const selection = window.getSelection?.(); if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null; const range = selection.getRangeAt(0); + if (!range.startContainer.isConnected || !range.endContainer.isConnected) return null; const startTextElement = assistantTextElementForSelectionNode(range.startContainer); const endTextElement = assistantTextElementForSelectionNode(range.endContainer); const text = selection.toString(); @@ -10702,6 +10706,10 @@ function selectedAssistantAnswer() { } function dismissSelectionAskAction() { + if (selectionAskActionRefreshFrame != null) { + cancelAnimationFrame(selectionAskActionRefreshFrame); + selectionAskActionRefreshFrame = null; + } pendingAnswerSelection = null; selectionAskActionEl?.classList.add('hidden'); } @@ -10709,7 +10717,10 @@ function dismissSelectionAskAction() { function positionSelectionAskAction(range) { if (!selectionAskActionEl || !range) return; const rect = range.getBoundingClientRect(); - if (!rect.width && !rect.height) return; + if (!rect.width && !rect.height) { + dismissSelectionAskAction(); + return; + } const gap = 6; const actionRect = selectionAskActionEl.getBoundingClientRect(); const left = Math.min( @@ -10735,23 +10746,35 @@ function refreshSelectionAskAction() { return; } pendingAnswerSelection = selected; - const label = getSelectionShortcutLocalization(getLocale()).strings.askQuestion; - selectionAskActionEl.textContent = label; - selectionAskActionEl.title = label; - selectionAskActionEl.setAttribute('aria-label', label); + const locale = getLocale(); + if (selectionAskActionLocale !== locale) { + selectionAskActionLocale = locale; + selectionAskActionLabel = getSelectionShortcutLocalization(locale).strings.askQuestion; + } + selectionAskActionEl.textContent = selectionAskActionLabel; + selectionAskActionEl.title = selectionAskActionLabel; + selectionAskActionEl.setAttribute('aria-label', selectionAskActionLabel); selectionAskActionEl.classList.remove('hidden'); - // selectionchange can be followed by a click before the next paint; correct - // the first position synchronously, then remeasure after layout settles. positionSelectionAskAction(selected.range); - requestAnimationFrame(() => { - if (pendingAnswerSelection === selected) positionSelectionAskAction(selected.range); +} + +function scheduleSelectionAskActionRefresh() { + if (selectionAskActionRefreshFrame != null) return; + selectionAskActionRefreshFrame = requestAnimationFrame(() => { + selectionAskActionRefreshFrame = null; + refreshSelectionAskAction(); }); } function askAboutSelectedAnswer() { const selection = pendingAnswerSelection; if (!selection) return; - const nextDraft = buildSelectionComposerDraft(selection.text, inputEl.value); + const liveSelection = selectedAssistantAnswer(); + if (!liveSelection) { + dismissSelectionAskAction(); + return; + } + const nextDraft = buildSelectionComposerDraft(liveSelection.text, inputEl.value); if (nextDraft === inputEl.value) { dismissSelectionAskAction(); return; @@ -11766,7 +11789,7 @@ async function handleGlobalKeydown(e) { if (selectionAskActionEl && !selectionAskActionEl.classList.contains('hidden')) { e.preventDefault(); dismissSelectionAskAction(); - return; + if (!isProcessing) return; } if (isProcessing) { e.preventDefault(); @@ -12780,13 +12803,13 @@ if (selectionAskActionEl) { event.stopPropagation(); askAboutSelectedAnswer(); }); - document.addEventListener('selectionchange', refreshSelectionAskAction); + document.addEventListener('selectionchange', scheduleSelectionAskActionRefresh); document.addEventListener('pointerdown', (event) => { if (!selectionAskActionEl.contains(event.target)) dismissSelectionAskAction(); }); chatContainerEl?.addEventListener('scroll', dismissSelectionAskAction, { passive: true }); window.addEventListener('resize', dismissSelectionAskAction); - document.addEventListener('wb-locale-changed', refreshSelectionAskAction); + document.addEventListener('wb-locale-changed', scheduleSelectionAskActionRefresh); } sendBtn.addEventListener('click', sendMessage); diff --git a/src/chrome/styles/sidepanel.css b/src/chrome/styles/sidepanel.css index c9057e562..bba6242b8 100644 --- a/src/chrome/styles/sidepanel.css +++ b/src/chrome/styles/sidepanel.css @@ -2310,6 +2310,8 @@ body { font-size: 10px; font-weight: 700; line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } diff --git a/src/firefox/src/ui/sidepanel.html b/src/firefox/src/ui/sidepanel.html index d1bd79aba..33700a2ed 100644 --- a/src/firefox/src/ui/sidepanel.html +++ b/src/firefox/src/ui/sidepanel.html @@ -311,7 +311,7 @@

Start a new c

- +
diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 83697e8fa..5df4cbf7e 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -469,6 +469,9 @@ const ASK_PLACEHOLDER_KEYS = [ ]; const PERMISSION_REMINDER_PLACEHOLDER_KEY = 'sp.input.placeholder_tip.skip_permissions'; let pendingAnswerSelection = null; +let selectionAskActionRefreshFrame = null; +let selectionAskActionLocale = ''; +let selectionAskActionLabel = ''; const SLASH_COMMANDS = [ { value: '/help', usage: '/help', descriptionKey: 'sp.slash.help', action: 'show', outOfBand: true }, { @@ -10315,6 +10318,7 @@ function selectedAssistantAnswer() { const selection = window.getSelection?.(); if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null; const range = selection.getRangeAt(0); + if (!range.startContainer.isConnected || !range.endContainer.isConnected) return null; const startTextElement = assistantTextElementForSelectionNode(range.startContainer); const endTextElement = assistantTextElementForSelectionNode(range.endContainer); const text = selection.toString(); @@ -10323,6 +10327,10 @@ function selectedAssistantAnswer() { } function dismissSelectionAskAction() { + if (selectionAskActionRefreshFrame != null) { + cancelAnimationFrame(selectionAskActionRefreshFrame); + selectionAskActionRefreshFrame = null; + } pendingAnswerSelection = null; selectionAskActionEl?.classList.add('hidden'); } @@ -10330,7 +10338,10 @@ function dismissSelectionAskAction() { function positionSelectionAskAction(range) { if (!selectionAskActionEl || !range) return; const rect = range.getBoundingClientRect(); - if (!rect.width && !rect.height) return; + if (!rect.width && !rect.height) { + dismissSelectionAskAction(); + return; + } const gap = 6; const actionRect = selectionAskActionEl.getBoundingClientRect(); const left = Math.min( @@ -10356,23 +10367,35 @@ function refreshSelectionAskAction() { return; } pendingAnswerSelection = selected; - const label = getSelectionShortcutLocalization(getLocale()).strings.askQuestion; - selectionAskActionEl.textContent = label; - selectionAskActionEl.title = label; - selectionAskActionEl.setAttribute('aria-label', label); + const locale = getLocale(); + if (selectionAskActionLocale !== locale) { + selectionAskActionLocale = locale; + selectionAskActionLabel = getSelectionShortcutLocalization(locale).strings.askQuestion; + } + selectionAskActionEl.textContent = selectionAskActionLabel; + selectionAskActionEl.title = selectionAskActionLabel; + selectionAskActionEl.setAttribute('aria-label', selectionAskActionLabel); selectionAskActionEl.classList.remove('hidden'); - // selectionchange can be followed by a click before the next paint; correct - // the first position synchronously, then remeasure after layout settles. positionSelectionAskAction(selected.range); - requestAnimationFrame(() => { - if (pendingAnswerSelection === selected) positionSelectionAskAction(selected.range); +} + +function scheduleSelectionAskActionRefresh() { + if (selectionAskActionRefreshFrame != null) return; + selectionAskActionRefreshFrame = requestAnimationFrame(() => { + selectionAskActionRefreshFrame = null; + refreshSelectionAskAction(); }); } function askAboutSelectedAnswer() { const selection = pendingAnswerSelection; if (!selection) return; - const nextDraft = buildSelectionComposerDraft(selection.text, inputEl.value); + const liveSelection = selectedAssistantAnswer(); + if (!liveSelection) { + dismissSelectionAskAction(); + return; + } + const nextDraft = buildSelectionComposerDraft(liveSelection.text, inputEl.value); if (nextDraft === inputEl.value) { dismissSelectionAskAction(); return; @@ -11353,7 +11376,7 @@ async function handleGlobalKeydown(e) { if (selectionAskActionEl && !selectionAskActionEl.classList.contains('hidden')) { e.preventDefault(); dismissSelectionAskAction(); - return; + if (!isProcessing) return; } // Provider/language pickers close on Escape in bubble/target handlers; do not // abort the active run while those listboxes are open. @@ -12265,13 +12288,13 @@ if (selectionAskActionEl) { event.stopPropagation(); askAboutSelectedAnswer(); }); - document.addEventListener('selectionchange', refreshSelectionAskAction); + document.addEventListener('selectionchange', scheduleSelectionAskActionRefresh); document.addEventListener('pointerdown', (event) => { if (!selectionAskActionEl.contains(event.target)) dismissSelectionAskAction(); }); chatContainerEl?.addEventListener('scroll', dismissSelectionAskAction, { passive: true }); window.addEventListener('resize', dismissSelectionAskAction); - document.addEventListener('wb-locale-changed', refreshSelectionAskAction); + document.addEventListener('wb-locale-changed', scheduleSelectionAskActionRefresh); } sendBtn.addEventListener('click', sendMessage); diff --git a/src/firefox/styles/sidepanel.css b/src/firefox/styles/sidepanel.css index 4957f56ea..bf889be28 100644 --- a/src/firefox/styles/sidepanel.css +++ b/src/firefox/styles/sidepanel.css @@ -2147,6 +2147,8 @@ body { font-size: 10px; font-weight: 700; line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } diff --git a/test/run.js b/test/run.js index d40ad9f0e..07054b3a4 100644 --- a/test/run.js +++ b/test/run.js @@ -1142,6 +1142,17 @@ const sidepanelSources = [ fs.readFileSync(path.join(ROOT, 'src/chrome/src/ui/sidepanel.js'), 'utf8'), fs.readFileSync(path.join(ROOT, 'src/firefox/src/ui/sidepanel.js'), 'utf8'), ]; +const selectionQuoteSources = [ + fs.readFileSync(path.join(ROOT, 'src/chrome/src/ui/selection-quote.js'), 'utf8'), + fs.readFileSync(path.join(ROOT, 'src/firefox/src/ui/selection-quote.js'), 'utf8'), +]; + +function sourceBetween(source, startMarker, endMarker) { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start + startMarker.length); + assert.ok(start >= 0 && end > start, `source markers missing: ${startMarker}`); + return source.slice(start, end); +} // ──────────────────────────────────────────────────────────────────────── // Test framework (one function, no deps) @@ -1175,19 +1186,26 @@ test('selectionIsQuoteable requires one non-empty assistant answer element', () assert.equal(selectionIsQuoteableFx({ ...valid, endTextElement: otherAnswer }), false); }); +test('selection quote helper stays byte-identical across browser builds', () => { + assert.equal(selectionQuoteSources[0], selectionQuoteSources[1]); +}); + test('selection answer action wiring covers show, dismiss, and tab/conversation changes in both sidepanels', () => { for (const source of sidepanelSources) { - const switchToTabSource = source.slice(source.indexOf('async function switchToTab'), source.indexOf('async function switchToTab') + 320); - const clearConversationSource = source.slice(source.indexOf('async function renderClearedConversationForTab'), source.indexOf('async function renderClearedConversationForTab') + 240); - const sendMessageSource = source.slice(source.indexOf('async function sendMessage'), source.indexOf('async function sendMessage') + 500); - assert.match(source, /document\.addEventListener\('selectionchange', refreshSelectionAskAction\)/); + const switchToTabSource = sourceBetween(source, 'async function switchToTab', '\n}\n\nasync function refreshVisibleSidePanelState'); + const clearConversationSource = sourceBetween(source, 'async function renderClearedConversationForTab', '\nconst TOOL_KEYS ='); + const sendMessageSource = sourceBetween(source, 'async function sendMessage', '\nasync function continueAgent'); + assert.match(source, /document\.addEventListener\('selectionchange', scheduleSelectionAskActionRefresh\)/); assert.match(source, /document\.addEventListener\('pointerdown', \(event\) => \{/); assert.match(source, /selectionAskActionEl\.addEventListener\('click'/); - assert.match(source, /if \(!rect\.width && !rect\.height\) return;/); + assert.match(source, /if \(!rect\.width && !rect\.height\) \{[\s\S]*?dismissSelectionAskAction\(\);/); + assert.match(source, /if \(!range\.startContainer\.isConnected \|\| !range\.endContainer\.isConnected\) return null;/); + assert.match(source, /const liveSelection = selectedAssistantAnswer\(\);/); assert.match(source, /selectionAskActionEl && !selectionAskActionEl\.classList\.contains\('hidden'\)[\s\S]*?dismissSelectionAskAction\(\);/); assert.match(switchToTabSource, /dismissSelectionAskAction\(\);/); assert.match(clearConversationSource, /dismissSelectionAskAction\(\);/); assert.match(sendMessageSource, /dismissSelectionAskAction\(\);/); + assert.match(source, /if \(!isProcessing\) return;/); } }); From d9df5a15206b5a5af3025676514380bc8b71ec08 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Tue, 18 Aug 2026 00:28:09 +0800 Subject: [PATCH 7/8] fix(ui): keep Escape run semantics --- src/chrome/src/ui/sidepanel.js | 2 +- src/firefox/src/ui/sidepanel.js | 2 +- test/run.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 804037e2f..f26a2143a 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -11789,7 +11789,7 @@ async function handleGlobalKeydown(e) { if (selectionAskActionEl && !selectionAskActionEl.classList.contains('hidden')) { e.preventDefault(); dismissSelectionAskAction(); - if (!isProcessing) return; + return; } if (isProcessing) { e.preventDefault(); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 5df4cbf7e..61fe6c72d 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -11376,7 +11376,7 @@ async function handleGlobalKeydown(e) { if (selectionAskActionEl && !selectionAskActionEl.classList.contains('hidden')) { e.preventDefault(); dismissSelectionAskAction(); - if (!isProcessing) return; + return; } // Provider/language pickers close on Escape in bubble/target handlers; do not // abort the active run while those listboxes are open. diff --git a/test/run.js b/test/run.js index 07054b3a4..c6628fe46 100644 --- a/test/run.js +++ b/test/run.js @@ -1205,7 +1205,7 @@ test('selection answer action wiring covers show, dismiss, and tab/conversation assert.match(switchToTabSource, /dismissSelectionAskAction\(\);/); assert.match(clearConversationSource, /dismissSelectionAskAction\(\);/); assert.match(sendMessageSource, /dismissSelectionAskAction\(\);/); - assert.match(source, /if \(!isProcessing\) return;/); + assert.match(source, /dismissSelectionAskAction\(\);\s*return;/); } }); From bde18a66acd1e74d40714b6d5465472002d1e748 Mon Sep 17 00:00:00 2001 From: release-verification Date: Tue, 18 Aug 2026 04:39:31 +0300 Subject: [PATCH 8/8] fix(ui): harden selection quote follow-up action Wait until pointerup before showing the chip so a downward drag does not hit the button, write the label only when it changes, and omit in-bubble chrome such as Copy from quoted text. Co-authored-by: Cursor --- src/chrome/src/ui/selection-quote.js | 43 ++++++++++++++++++++ src/chrome/src/ui/sidepanel.html | 2 +- src/chrome/src/ui/sidepanel.js | 57 ++++++++++++++++++++------- src/chrome/styles/sidepanel.css | 1 + src/firefox/src/ui/selection-quote.js | 43 ++++++++++++++++++++ src/firefox/src/ui/sidepanel.html | 2 +- src/firefox/src/ui/sidepanel.js | 57 ++++++++++++++++++++------- src/firefox/styles/sidepanel.css | 1 + test/run.js | 57 ++++++++++++++++++++++++++- 9 files changed, 229 insertions(+), 34 deletions(-) diff --git a/src/chrome/src/ui/selection-quote.js b/src/chrome/src/ui/selection-quote.js index a51e95877..026329071 100644 --- a/src/chrome/src/ui/selection-quote.js +++ b/src/chrome/src/ui/selection-quote.js @@ -1,7 +1,50 @@ +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; +const QUOTE_CHROME_TAGS = new Set(['BUTTON', 'INPUT', 'TEXTAREA', 'SELECT', 'SCRIPT', 'STYLE', 'TEMPLATE']); +const QUOTE_CHROME_CLASSES = new Set(['code-block-header', 'code-copy-btn', 'code-lang', 'msg-copy-btn']); + function normalizedSelectionText(text) { return String(text == null ? '' : text).replace(/\r\n?/g, '\n').trim(); } +function classListContains(node, className) { + if (node?.classList?.contains?.(className)) return true; + const classNameValue = typeof node?.className === 'string' ? node.className : ''; + return classNameValue.split(/\s+/).includes(className); +} + +export function isSelectionQuoteChrome(node) { + if (!node || node.nodeType !== ELEMENT_NODE) return false; + if (QUOTE_CHROME_TAGS.has(String(node.tagName || '').toUpperCase())) return true; + for (const className of QUOTE_CHROME_CLASSES) { + if (classListContains(node, className)) return true; + } + return false; +} + +function collectSelectionQuoteText(node) { + if (!node) return ''; + if (node.nodeType === TEXT_NODE) return String(node.nodeValue ?? node.textContent ?? ''); + if (node.nodeType !== ELEMENT_NODE) return ''; + if (isSelectionQuoteChrome(node)) return ''; + if (String(node.tagName || '').toUpperCase() === 'BR') return '\n'; + let text = ''; + for (const child of node.childNodes || []) text += collectSelectionQuoteText(child); + return text; +} + +export function selectionTextFromContents(root) { + return normalizedSelectionText(collectSelectionQuoteText(root)); +} + +export function selectionTextFromRange(range) { + if (!range) return ''; + if (typeof range.cloneContents === 'function') { + return selectionTextFromContents(range.cloneContents()); + } + return normalizedSelectionText(range.toString?.() || ''); +} + export function buildSelectionQuote(text) { const selection = normalizedSelectionText(text); if (!selection) return ''; diff --git a/src/chrome/src/ui/sidepanel.html b/src/chrome/src/ui/sidepanel.html index bc1426a99..194b7c06f 100644 --- a/src/chrome/src/ui/sidepanel.html +++ b/src/chrome/src/ui/sidepanel.html @@ -356,7 +356,7 @@

Start a new c

- +
diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index f26a2143a..edb77c9a0 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -29,7 +29,7 @@ import { runUiUnavailableBeforeSeq } from '../run-ui-journal.js'; import { formatErrorMessage } from '../error-format.js'; import { buildMessageInfoPills } from '../message-info.js'; import { escapeHtml } from './utils.js'; -import { buildSelectionComposerDraft, selectionIsQuoteable } from './selection-quote.js'; +import { buildSelectionComposerDraft, selectionIsQuoteable, selectionTextFromRange } from './selection-quote.js'; import { getSelectionShortcutLocalization } from '../selection-shortcut-i18n.js'; import { isBackgroundConnectionError, @@ -594,6 +594,7 @@ const ASK_PLACEHOLDER_KEYS = [ const PERMISSION_REMINDER_PLACEHOLDER_KEY = 'sp.input.placeholder_tip.skip_permissions'; let pendingAnswerSelection = null; let selectionAskActionRefreshFrame = null; +let selectionAskPointerDown = false; let selectionAskActionLocale = ''; let selectionAskActionLabel = ''; const SLASH_COMMANDS = [ @@ -10700,7 +10701,7 @@ function selectedAssistantAnswer() { if (!range.startContainer.isConnected || !range.endContainer.isConnected) return null; const startTextElement = assistantTextElementForSelectionNode(range.startContainer); const endTextElement = assistantTextElementForSelectionNode(range.endContainer); - const text = selection.toString(); + const text = selectionTextFromRange(range); if (!selectionIsQuoteable({ startTextElement, endTextElement, text })) return null; return { range, text }; } @@ -10739,6 +10740,20 @@ function positionSelectionAskAction(range) { selectionAskActionEl.style.top = `${top}px`; } +function applySelectionAskActionLabel() { + if (!selectionAskActionEl) return; + const locale = getLocale(); + if (selectionAskActionLocale === locale && selectionAskActionLabel + && selectionAskActionEl.textContent === selectionAskActionLabel) { + return; + } + selectionAskActionLocale = locale; + selectionAskActionLabel = getSelectionShortcutLocalization(locale).strings.askQuestion; + selectionAskActionEl.textContent = selectionAskActionLabel; + selectionAskActionEl.title = selectionAskActionLabel; + selectionAskActionEl.setAttribute('aria-label', selectionAskActionLabel); +} + function refreshSelectionAskAction() { const selected = selectedAssistantAnswer(); if (!selected || !selectionAskActionEl) { @@ -10746,26 +10761,33 @@ function refreshSelectionAskAction() { return; } pendingAnswerSelection = selected; - const locale = getLocale(); - if (selectionAskActionLocale !== locale) { - selectionAskActionLocale = locale; - selectionAskActionLabel = getSelectionShortcutLocalization(locale).strings.askQuestion; - } - selectionAskActionEl.textContent = selectionAskActionLabel; - selectionAskActionEl.title = selectionAskActionLabel; - selectionAskActionEl.setAttribute('aria-label', selectionAskActionLabel); + applySelectionAskActionLabel(); selectionAskActionEl.classList.remove('hidden'); positionSelectionAskAction(selected.range); } -function scheduleSelectionAskActionRefresh() { +function scheduleSelectionAskActionRefresh({ force = false } = {}) { + if (!force && selectionAskPointerDown) return; if (selectionAskActionRefreshFrame != null) return; selectionAskActionRefreshFrame = requestAnimationFrame(() => { selectionAskActionRefreshFrame = null; + if (!force && selectionAskPointerDown) return; refreshSelectionAskAction(); }); } +function handleSelectionAskPointerDown(event) { + if (selectionAskActionEl?.contains(event.target)) return; + selectionAskPointerDown = true; + dismissSelectionAskAction(); +} + +function handleSelectionAskPointerUp() { + if (!selectionAskPointerDown) return; + selectionAskPointerDown = false; + scheduleSelectionAskActionRefresh({ force: true }); +} + function askAboutSelectedAnswer() { const selection = pendingAnswerSelection; if (!selection) return; @@ -12804,12 +12826,17 @@ if (selectionAskActionEl) { askAboutSelectedAnswer(); }); document.addEventListener('selectionchange', scheduleSelectionAskActionRefresh); - document.addEventListener('pointerdown', (event) => { - if (!selectionAskActionEl.contains(event.target)) dismissSelectionAskAction(); - }); + document.addEventListener('pointerdown', handleSelectionAskPointerDown); + document.addEventListener('pointerup', handleSelectionAskPointerUp); + document.addEventListener('pointercancel', handleSelectionAskPointerUp); + document.addEventListener('keyup', scheduleSelectionAskActionRefresh); chatContainerEl?.addEventListener('scroll', dismissSelectionAskAction, { passive: true }); window.addEventListener('resize', dismissSelectionAskAction); - document.addEventListener('wb-locale-changed', scheduleSelectionAskActionRefresh); + document.addEventListener('wb-locale-changed', () => { + selectionAskActionLocale = ''; + applySelectionAskActionLabel(); + scheduleSelectionAskActionRefresh(); + }); } sendBtn.addEventListener('click', sendMessage); diff --git a/src/chrome/styles/sidepanel.css b/src/chrome/styles/sidepanel.css index bba6242b8..1074cdf7b 100644 --- a/src/chrome/styles/sidepanel.css +++ b/src/chrome/styles/sidepanel.css @@ -2354,6 +2354,7 @@ body { font-size: 11px; font-weight: 700; line-height: 1.2; + user-select: none; white-space: nowrap; } diff --git a/src/firefox/src/ui/selection-quote.js b/src/firefox/src/ui/selection-quote.js index a51e95877..026329071 100644 --- a/src/firefox/src/ui/selection-quote.js +++ b/src/firefox/src/ui/selection-quote.js @@ -1,7 +1,50 @@ +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; +const QUOTE_CHROME_TAGS = new Set(['BUTTON', 'INPUT', 'TEXTAREA', 'SELECT', 'SCRIPT', 'STYLE', 'TEMPLATE']); +const QUOTE_CHROME_CLASSES = new Set(['code-block-header', 'code-copy-btn', 'code-lang', 'msg-copy-btn']); + function normalizedSelectionText(text) { return String(text == null ? '' : text).replace(/\r\n?/g, '\n').trim(); } +function classListContains(node, className) { + if (node?.classList?.contains?.(className)) return true; + const classNameValue = typeof node?.className === 'string' ? node.className : ''; + return classNameValue.split(/\s+/).includes(className); +} + +export function isSelectionQuoteChrome(node) { + if (!node || node.nodeType !== ELEMENT_NODE) return false; + if (QUOTE_CHROME_TAGS.has(String(node.tagName || '').toUpperCase())) return true; + for (const className of QUOTE_CHROME_CLASSES) { + if (classListContains(node, className)) return true; + } + return false; +} + +function collectSelectionQuoteText(node) { + if (!node) return ''; + if (node.nodeType === TEXT_NODE) return String(node.nodeValue ?? node.textContent ?? ''); + if (node.nodeType !== ELEMENT_NODE) return ''; + if (isSelectionQuoteChrome(node)) return ''; + if (String(node.tagName || '').toUpperCase() === 'BR') return '\n'; + let text = ''; + for (const child of node.childNodes || []) text += collectSelectionQuoteText(child); + return text; +} + +export function selectionTextFromContents(root) { + return normalizedSelectionText(collectSelectionQuoteText(root)); +} + +export function selectionTextFromRange(range) { + if (!range) return ''; + if (typeof range.cloneContents === 'function') { + return selectionTextFromContents(range.cloneContents()); + } + return normalizedSelectionText(range.toString?.() || ''); +} + export function buildSelectionQuote(text) { const selection = normalizedSelectionText(text); if (!selection) return ''; diff --git a/src/firefox/src/ui/sidepanel.html b/src/firefox/src/ui/sidepanel.html index 33700a2ed..d1bd79aba 100644 --- a/src/firefox/src/ui/sidepanel.html +++ b/src/firefox/src/ui/sidepanel.html @@ -311,7 +311,7 @@

Start a new c

- +
diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 61fe6c72d..a1041785d 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -29,7 +29,7 @@ import { runUiUnavailableBeforeSeq } from '../run-ui-journal.js'; import { formatErrorMessage } from '../error-format.js'; import { buildMessageInfoPills } from '../message-info.js'; import { escapeHtml } from './utils.js'; -import { buildSelectionComposerDraft, selectionIsQuoteable } from './selection-quote.js'; +import { buildSelectionComposerDraft, selectionIsQuoteable, selectionTextFromRange } from './selection-quote.js'; import { getSelectionShortcutLocalization } from '../selection-shortcut-i18n.js'; import { isBackgroundConnectionError, @@ -470,6 +470,7 @@ const ASK_PLACEHOLDER_KEYS = [ const PERMISSION_REMINDER_PLACEHOLDER_KEY = 'sp.input.placeholder_tip.skip_permissions'; let pendingAnswerSelection = null; let selectionAskActionRefreshFrame = null; +let selectionAskPointerDown = false; let selectionAskActionLocale = ''; let selectionAskActionLabel = ''; const SLASH_COMMANDS = [ @@ -10321,7 +10322,7 @@ function selectedAssistantAnswer() { if (!range.startContainer.isConnected || !range.endContainer.isConnected) return null; const startTextElement = assistantTextElementForSelectionNode(range.startContainer); const endTextElement = assistantTextElementForSelectionNode(range.endContainer); - const text = selection.toString(); + const text = selectionTextFromRange(range); if (!selectionIsQuoteable({ startTextElement, endTextElement, text })) return null; return { range, text }; } @@ -10360,6 +10361,20 @@ function positionSelectionAskAction(range) { selectionAskActionEl.style.top = `${top}px`; } +function applySelectionAskActionLabel() { + if (!selectionAskActionEl) return; + const locale = getLocale(); + if (selectionAskActionLocale === locale && selectionAskActionLabel + && selectionAskActionEl.textContent === selectionAskActionLabel) { + return; + } + selectionAskActionLocale = locale; + selectionAskActionLabel = getSelectionShortcutLocalization(locale).strings.askQuestion; + selectionAskActionEl.textContent = selectionAskActionLabel; + selectionAskActionEl.title = selectionAskActionLabel; + selectionAskActionEl.setAttribute('aria-label', selectionAskActionLabel); +} + function refreshSelectionAskAction() { const selected = selectedAssistantAnswer(); if (!selected || !selectionAskActionEl) { @@ -10367,26 +10382,33 @@ function refreshSelectionAskAction() { return; } pendingAnswerSelection = selected; - const locale = getLocale(); - if (selectionAskActionLocale !== locale) { - selectionAskActionLocale = locale; - selectionAskActionLabel = getSelectionShortcutLocalization(locale).strings.askQuestion; - } - selectionAskActionEl.textContent = selectionAskActionLabel; - selectionAskActionEl.title = selectionAskActionLabel; - selectionAskActionEl.setAttribute('aria-label', selectionAskActionLabel); + applySelectionAskActionLabel(); selectionAskActionEl.classList.remove('hidden'); positionSelectionAskAction(selected.range); } -function scheduleSelectionAskActionRefresh() { +function scheduleSelectionAskActionRefresh({ force = false } = {}) { + if (!force && selectionAskPointerDown) return; if (selectionAskActionRefreshFrame != null) return; selectionAskActionRefreshFrame = requestAnimationFrame(() => { selectionAskActionRefreshFrame = null; + if (!force && selectionAskPointerDown) return; refreshSelectionAskAction(); }); } +function handleSelectionAskPointerDown(event) { + if (selectionAskActionEl?.contains(event.target)) return; + selectionAskPointerDown = true; + dismissSelectionAskAction(); +} + +function handleSelectionAskPointerUp() { + if (!selectionAskPointerDown) return; + selectionAskPointerDown = false; + scheduleSelectionAskActionRefresh({ force: true }); +} + function askAboutSelectedAnswer() { const selection = pendingAnswerSelection; if (!selection) return; @@ -12289,12 +12311,17 @@ if (selectionAskActionEl) { askAboutSelectedAnswer(); }); document.addEventListener('selectionchange', scheduleSelectionAskActionRefresh); - document.addEventListener('pointerdown', (event) => { - if (!selectionAskActionEl.contains(event.target)) dismissSelectionAskAction(); - }); + document.addEventListener('pointerdown', handleSelectionAskPointerDown); + document.addEventListener('pointerup', handleSelectionAskPointerUp); + document.addEventListener('pointercancel', handleSelectionAskPointerUp); + document.addEventListener('keyup', scheduleSelectionAskActionRefresh); chatContainerEl?.addEventListener('scroll', dismissSelectionAskAction, { passive: true }); window.addEventListener('resize', dismissSelectionAskAction); - document.addEventListener('wb-locale-changed', scheduleSelectionAskActionRefresh); + document.addEventListener('wb-locale-changed', () => { + selectionAskActionLocale = ''; + applySelectionAskActionLabel(); + scheduleSelectionAskActionRefresh(); + }); } sendBtn.addEventListener('click', sendMessage); diff --git a/src/firefox/styles/sidepanel.css b/src/firefox/styles/sidepanel.css index bf889be28..244326f59 100644 --- a/src/firefox/styles/sidepanel.css +++ b/src/firefox/styles/sidepanel.css @@ -2191,6 +2191,7 @@ body { font-size: 11px; font-weight: 700; line-height: 1.2; + user-select: none; white-space: nowrap; } diff --git a/test/run.js b/test/run.js index c6628fe46..ec0145a18 100644 --- a/test/run.js +++ b/test/run.js @@ -1128,6 +1128,8 @@ const { buildSelectionQuote, buildSelectionComposerDraft, selectionIsQuoteable, + selectionTextFromContents, + isSelectionQuoteChrome, } = await import( 'file://' + path.join(ROOT, 'src/chrome/src/ui/selection-quote.js').replace(/\\/g, '/') ); @@ -1135,6 +1137,8 @@ const { buildSelectionQuote: buildSelectionQuoteFx, buildSelectionComposerDraft: buildSelectionComposerDraftFx, selectionIsQuoteable: selectionIsQuoteableFx, + selectionTextFromContents: selectionTextFromContentsFx, + isSelectionQuoteChrome: isSelectionQuoteChromeFx, } = await import( 'file://' + path.join(ROOT, 'src/firefox/src/ui/selection-quote.js').replace(/\\/g, '/') ); @@ -1142,6 +1146,14 @@ const sidepanelSources = [ fs.readFileSync(path.join(ROOT, 'src/chrome/src/ui/sidepanel.js'), 'utf8'), fs.readFileSync(path.join(ROOT, 'src/firefox/src/ui/sidepanel.js'), 'utf8'), ]; +const sidepanelHtmlSources = [ + fs.readFileSync(path.join(ROOT, 'src/chrome/src/ui/sidepanel.html'), 'utf8'), + fs.readFileSync(path.join(ROOT, 'src/firefox/src/ui/sidepanel.html'), 'utf8'), +]; +const sidepanelStyleSources = [ + fs.readFileSync(path.join(ROOT, 'src/chrome/styles/sidepanel.css'), 'utf8'), + fs.readFileSync(path.join(ROOT, 'src/firefox/styles/sidepanel.css'), 'utf8'), +]; const selectionQuoteSources = [ fs.readFileSync(path.join(ROOT, 'src/chrome/src/ui/selection-quote.js'), 'utf8'), fs.readFileSync(path.join(ROOT, 'src/firefox/src/ui/selection-quote.js'), 'utf8'), @@ -1190,13 +1202,51 @@ test('selection quote helper stays byte-identical across browser builds', () => assert.equal(selectionQuoteSources[0], selectionQuoteSources[1]); }); +test('selectionTextFromContents skips in-bubble chrome and keeps answer text', () => { + const textNode = (value) => ({ nodeType: 3, nodeValue: value }); + const element = (tagName, className, ...childNodes) => ({ + nodeType: 1, + tagName, + className, + classList: { contains: (name) => String(className || '').split(/\s+/).includes(name) }, + childNodes, + }); + const tree = element( + 'DIV', + 'message-text', + textNode('Intro '), + element('DIV', 'code-block-wrapper', + element('DIV', 'code-block-header', + element('SPAN', 'code-lang', textNode('javascript')), + element('BUTTON', 'code-copy-btn', textNode('Copy')), + ), + element('PRE', '', element('CODE', '', textNode('const x = 1;'))), + ), + element('BR', ''), + textNode('Outro'), + ); + const expected = 'Intro const x = 1;\nOutro'; + assert.equal(selectionTextFromContents(tree), expected); + assert.equal(selectionTextFromContentsFx(tree), expected, 'Firefox chrome-stripping should match Chrome'); + assert.equal(isSelectionQuoteChrome(element('BUTTON', 'code-copy-btn', textNode('Copy'))), true); + assert.equal(isSelectionQuoteChromeFx(element('SPAN', 'code-lang', textNode('javascript'))), true); + assert.equal(isSelectionQuoteChrome(element('CODE', '', textNode('const x = 1;'))), false); +}); + test('selection answer action wiring covers show, dismiss, and tab/conversation changes in both sidepanels', () => { - for (const source of sidepanelSources) { + for (const [index, source] of sidepanelSources.entries()) { const switchToTabSource = sourceBetween(source, 'async function switchToTab', '\n}\n\nasync function refreshVisibleSidePanelState'); const clearConversationSource = sourceBetween(source, 'async function renderClearedConversationForTab', '\nconst TOOL_KEYS ='); const sendMessageSource = sourceBetween(source, 'async function sendMessage', '\nasync function continueAgent'); assert.match(source, /document\.addEventListener\('selectionchange', scheduleSelectionAskActionRefresh\)/); - assert.match(source, /document\.addEventListener\('pointerdown', \(event\) => \{/); + assert.match(source, /document\.addEventListener\('pointerdown', handleSelectionAskPointerDown\)/); + assert.match(source, /document\.addEventListener\('pointerup', handleSelectionAskPointerUp\)/); + assert.match(source, /document\.addEventListener\('pointercancel', handleSelectionAskPointerUp\)/); + assert.match(source, /document\.addEventListener\('keyup', scheduleSelectionAskActionRefresh\)/); + assert.match(source, /if \(!force && selectionAskPointerDown\) return;/); + assert.match(source, /const text = selectionTextFromRange\(range\);/); + assert.match(source, /function applySelectionAskActionLabel\(\)/); + assert.match(source, /if \(selectionAskActionLocale === locale && selectionAskActionLabel[\s\S]*?selectionAskActionEl\.textContent === selectionAskActionLabel\)/); assert.match(source, /selectionAskActionEl\.addEventListener\('click'/); assert.match(source, /if \(!rect\.width && !rect\.height\) \{[\s\S]*?dismissSelectionAskAction\(\);/); assert.match(source, /if \(!range\.startContainer\.isConnected \|\| !range\.endContainer\.isConnected\) return null;/); @@ -1206,6 +1256,9 @@ test('selection answer action wiring covers show, dismiss, and tab/conversation assert.match(clearConversationSource, /dismissSelectionAskAction\(\);/); assert.match(sendMessageSource, /dismissSelectionAskAction\(\);/); assert.match(source, /dismissSelectionAskAction\(\);\s*return;/); + assert.match(sidepanelHtmlSources[index], /id="selection-ask-action"/); + assert.doesNotMatch(sidepanelHtmlSources[index], /id="selection-ask-action"[^>]*aria-live/); + assert.match(sidepanelStyleSources[index], /\.selection-ask-action \{[\s\S]*?user-select:\s*none;/); } });