Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/chrome/src/ui/selection-quote.js
Original file line number Diff line number Diff line change
@@ -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),
);
}
2 changes: 2 additions & 0 deletions src/chrome/src/ui/sidepanel.html
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ <h2 id="new-conversation-confirm-title" data-i18n="sp.clear.title">Start a new c
<button id="selection-scope-new-conversation" type="button" data-i18n="sp.btn.clear">New conversation</button>
</div>

<button id="selection-ask-action" class="selection-ask-action hidden" type="button"></button>

<!-- Input Area -->
<div id="input-area">
<div id="mode-toggle">
Expand Down
156 changes: 156 additions & 0 deletions src/chrome/src/ui/sidepanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 },
{
Expand Down Expand Up @@ -2252,6 +2260,7 @@ function drainQueuedComposerMessageForCurrentTab() {
}

async function renderClearedConversationForTab(tabId) {
dismissSelectionAskAction();
setSelectionGroundedForTab(tabId, false);
const clearResult = await clearCachedTabChat(tabId);
if (!clearResult?.ok || clearResult?.skipped) {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions src/chrome/styles/sidepanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -2310,6 +2310,8 @@ body {
font-size: 10px;
font-weight: 700;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

Expand Down Expand Up @@ -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 */
Expand Down
Loading
Loading