diff --git a/src/chrome/src/ui/selection-quote.js b/src/chrome/src/ui/selection-quote.js new file mode 100644 index 000000000..026329071 --- /dev/null +++ b/src/chrome/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/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..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', () => {