diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js
index f9e6ee0a3..edb77c9a0 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, selectionTextFromRange } 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,11 @@ const ASK_PLACEHOLDER_KEYS = [
'sp.input.placeholder_tip.record',
];
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 = [
{ value: '/help', usage: '/help', descriptionKey: 'sp.slash.help', action: 'show', outOfBand: true },
{
@@ -2252,6 +2260,7 @@ function drainQueuedComposerMessageForCurrentTab() {
}
async function renderClearedConversationForTab(tabId) {
+ dismissSelectionAskAction();
setSelectionGroundedForTab(tabId, false);
const clearResult = await clearCachedTabChat(tabId);
if (!clearResult?.ok || clearResult?.skipped) {
@@ -4208,6 +4217,7 @@ if (verboseBtn) {
async function switchToTab(newTabId) {
if (newTabId === currentTabId && renderedTabId === newTabId) { return; }
+ dismissSelectionAskAction();
if (newConversationConfirmationState
&& !sameTabId(newConversationConfirmationState.tabId, newTabId)) {
settleNewConversationConfirmation(false, { restoreFocus: false });
@@ -7854,6 +7864,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'
@@ -10678,6 +10689,126 @@ 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);
+ if (!range.startContainer.isConnected || !range.endContainer.isConnected) return null;
+ const startTextElement = assistantTextElementForSelectionNode(range.startContainer);
+ const endTextElement = assistantTextElementForSelectionNode(range.endContainer);
+ const text = selectionTextFromRange(range);
+ if (!selectionIsQuoteable({ startTextElement, endTextElement, text })) return null;
+ return { range, text };
+}
+
+function dismissSelectionAskAction() {
+ if (selectionAskActionRefreshFrame != null) {
+ cancelAnimationFrame(selectionAskActionRefreshFrame);
+ selectionAskActionRefreshFrame = null;
+ }
+ 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 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`;
+}
+
+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) {
+ dismissSelectionAskAction();
+ return;
+ }
+ pendingAnswerSelection = selected;
+ applySelectionAskActionLabel();
+ selectionAskActionEl.classList.remove('hidden');
+ positionSelectionAskAction(selected.range);
+}
+
+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;
+ const liveSelection = selectedAssistantAnswer();
+ if (!liveSelection) {
+ dismissSelectionAskAction();
+ return;
+ }
+ const nextDraft = buildSelectionComposerDraft(liveSelection.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}`;
@@ -11677,6 +11808,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();
@@ -12683,6 +12819,26 @@ if (attachBtn && fileAttachInput) {
// --- Event Listeners ---
+if (selectionAskActionEl) {
+ selectionAskActionEl.addEventListener('mousedown', (event) => event.preventDefault());
+ selectionAskActionEl.addEventListener('click', (event) => {
+ event.stopPropagation();
+ askAboutSelectedAnswer();
+ });
+ document.addEventListener('selectionchange', scheduleSelectionAskActionRefresh);
+ 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', () => {
+ selectionAskActionLocale = '';
+ applySelectionAskActionLabel();
+ scheduleSelectionAskActionRefresh();
+ });
+}
+
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..1074cdf7b 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;
}
@@ -2337,6 +2339,39 @@ 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;
+ user-select: none;
+ 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;
+}
+
/* 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..026329071
--- /dev/null
+++ b/src/firefox/src/ui/selection-quote.js
@@ -0,0 +1,68 @@
+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 '';
+ 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);
+ if (!quote || existingDraft.startsWith(quote)) return existingDraft;
+ return `${quote}${existingDraft}`;
+}
+
+export function selectionIsQuoteable({ startTextElement, endTextElement, text } = {}) {
+ // A range spanning two bubbles has no unambiguous answer boundary.
+ 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..a1041785d 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, selectionTextFromRange } 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,11 @@ const ASK_PLACEHOLDER_KEYS = [
'sp.input.placeholder_tip.help',
];
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 = [
{ value: '/help', usage: '/help', descriptionKey: 'sp.slash.help', action: 'show', outOfBand: true },
{
@@ -2415,6 +2423,7 @@ function drainQueuedComposerMessageForCurrentTab() {
}
async function renderClearedConversationForTab(tabId) {
+ dismissSelectionAskAction();
setSelectionGroundedForTab(tabId, false);
const clearResult = await clearCachedTabChat(tabId);
if (!clearResult?.ok || clearResult?.skipped) {
@@ -4054,6 +4063,7 @@ if (verboseBtn) {
async function switchToTab(newTabId) {
if (newTabId === currentTabId && renderedTabId === newTabId) { return; }
+ dismissSelectionAskAction();
if (newConversationConfirmationState
&& !sameTabId(newConversationConfirmationState.tabId, newTabId)) {
settleNewConversationConfirmation(false, { restoreFocus: false });
@@ -7529,6 +7539,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'
@@ -10299,6 +10310,126 @@ 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);
+ if (!range.startContainer.isConnected || !range.endContainer.isConnected) return null;
+ const startTextElement = assistantTextElementForSelectionNode(range.startContainer);
+ const endTextElement = assistantTextElementForSelectionNode(range.endContainer);
+ const text = selectionTextFromRange(range);
+ if (!selectionIsQuoteable({ startTextElement, endTextElement, text })) return null;
+ return { range, text };
+}
+
+function dismissSelectionAskAction() {
+ if (selectionAskActionRefreshFrame != null) {
+ cancelAnimationFrame(selectionAskActionRefreshFrame);
+ selectionAskActionRefreshFrame = null;
+ }
+ 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 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`;
+}
+
+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) {
+ dismissSelectionAskAction();
+ return;
+ }
+ pendingAnswerSelection = selected;
+ applySelectionAskActionLabel();
+ selectionAskActionEl.classList.remove('hidden');
+ positionSelectionAskAction(selected.range);
+}
+
+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;
+ const liveSelection = selectedAssistantAnswer();
+ if (!liveSelection) {
+ dismissSelectionAskAction();
+ return;
+ }
+ const nextDraft = buildSelectionComposerDraft(liveSelection.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}`;
@@ -11264,6 +11395,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');
@@ -12168,6 +12304,26 @@ if (attachBtn && fileAttachInput) {
// --- Event Listeners ---
+if (selectionAskActionEl) {
+ selectionAskActionEl.addEventListener('mousedown', (event) => event.preventDefault());
+ selectionAskActionEl.addEventListener('click', (event) => {
+ event.stopPropagation();
+ askAboutSelectedAnswer();
+ });
+ document.addEventListener('selectionchange', scheduleSelectionAskActionRefresh);
+ 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', () => {
+ selectionAskActionLocale = '';
+ applySelectionAskActionLabel();
+ scheduleSelectionAskActionRefresh();
+ });
+}
+
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..244326f59 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;
}
@@ -2174,6 +2176,39 @@ 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;
+ user-select: none;
+ 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;
+}
+
/* Context-aware recommendations — inline centered pill row in the chat body */
diff --git a/test/run.js b/test/run.js
index 2acb0ed09..ec0145a18 100644
--- a/test/run.js
+++ b/test/run.js
@@ -1124,6 +1124,48 @@ const {
'file://' + path.join(ROOT, 'src/firefox/src/agent/sheets-tools.js').replace(/\\/g, '/')
);
+const {
+ buildSelectionQuote,
+ buildSelectionComposerDraft,
+ selectionIsQuoteable,
+ selectionTextFromContents,
+ isSelectionQuoteChrome,
+} = await import(
+ 'file://' + path.join(ROOT, 'src/chrome/src/ui/selection-quote.js').replace(/\\/g, '/')
+);
+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, '/')
+);
+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'),
+];
+
+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)
// ────────────────────────────────────────────────────────────────────────
@@ -1131,6 +1173,95 @@ 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');
+ 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', () => {
+ 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);
+});
+
+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 [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', 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;/);
+ 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, /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;/);
+ }
+});
+
console.log('\nscreenshot redaction');
test('selectRedactionRegions blurs password fields always', () => {