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
62 changes: 45 additions & 17 deletions src/chrome/src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ import {
normalizeSelectionAction,
createContextMenuStorage,
} from './context-menu-storage.js';
import {
getSelectionShortcutLocalization,
normalizeSelectionShortcutLocale,
selectionTranslationLanguageLabel,
} from './selection-shortcut-i18n.js';
import { createTabChatHandoffCoordinator } from './ui/tab-chat-persistence.js';
import { clearStagedScreenshots } from './ui/staged-screenshot-store.js';
import {
Expand Down Expand Up @@ -193,6 +198,12 @@ const CONTEXT_MENU_ACTION_PREFIX = 'webbrain-selection-action-';
const CONTEXT_MENU_TRANSLATE_ID = 'webbrain-selection-translate';
const CONTEXT_MENU_TRANSLATE_PREFIX = 'webbrain-selection-translate-';
const CONTEXT_MENU_GENERIC_ASK_ID = 'webbrain-selection-generic-ask';
let selectionShortcutLocale = 'en';
const selectionShortcutLocaleReady = chrome.storage.local.get({ wbLocale: 'en' })
.then((stored) => {
selectionShortcutLocale = normalizeSelectionShortcutLocale(stored?.wbLocale);
})
.catch(() => {});

function getContextMenuPromptStore() {
return chrome.storage?.session || chrome.storage?.local || null;
Expand All @@ -215,8 +226,11 @@ const tabChatHandoff = createTabChatHandoffCoordinator(chrome.storage.session, {
},
});

function createContextMenus() {
async function createContextMenus() {
await selectionShortcutLocaleReady;
if (!chrome.contextMenus?.create) return;
const localization = getSelectionShortcutLocalization(selectionShortcutLocale);
const strings = localization.strings;

const create = (item) => {
chrome.contextMenus.create(item, () => {
Expand All @@ -231,26 +245,31 @@ function createContextMenus() {
void chrome.runtime.lastError;
create({
id: CONTEXT_MENU_ASK_SELECTION_ID,
title: 'Ask WebBrain about this',
title: strings.askSelection,
contexts: ['selection'],
});
create({ id: CONTEXT_MENU_OPEN_CHAT_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Open side panel to chat', contexts: ['selection'] });
create({ id: CONTEXT_MENU_OPEN_CHAT_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings.openChat, contexts: ['selection'] });
create({ id: 'webbrain-selection-separator-1', parentId: CONTEXT_MENU_ASK_SELECTION_ID, type: 'separator', contexts: ['selection'] });
for (const [action, title] of [
['summarize', 'Summarize'],
['explain', 'Explain'],
['quiz', 'Quiz me'],
['proofread', 'Proofread'],
['humanize', 'Humanize'],
for (const [action, key] of [
['summarize', 'summarize'],
['explain', 'explain'],
['quiz', 'quiz'],
['proofread', 'proofread'],
['humanize', 'humanize'],
]) {
create({ id: `${CONTEXT_MENU_ACTION_PREFIX}${action}`, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title, contexts: ['selection'] });
create({ id: `${CONTEXT_MENU_ACTION_PREFIX}${action}`, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings[key], contexts: ['selection'] });
}
create({ id: CONTEXT_MENU_TRANSLATE_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Translate to', contexts: ['selection'] });
create({ id: CONTEXT_MENU_TRANSLATE_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings.translateTo, contexts: ['selection'] });
for (const [code, title] of Object.entries(SELECTION_TRANSLATION_LANGUAGES)) {
create({ id: `${CONTEXT_MENU_TRANSLATE_PREFIX}${code}`, parentId: CONTEXT_MENU_TRANSLATE_ID, title, contexts: ['selection'] });
create({
id: `${CONTEXT_MENU_TRANSLATE_PREFIX}${code}`,
parentId: CONTEXT_MENU_TRANSLATE_ID,
title: selectionTranslationLanguageLabel(code, localization.locale) || title,
contexts: ['selection'],
});
}
create({ id: 'webbrain-selection-separator-2', parentId: CONTEXT_MENU_ASK_SELECTION_ID, type: 'separator', contexts: ['selection'] });
create({ id: CONTEXT_MENU_GENERIC_ASK_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Ask about this', contexts: ['selection'] });
create({ id: CONTEXT_MENU_GENERIC_ASK_ID, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: strings.askAbout, contexts: ['selection'] });
});
}

Expand Down Expand Up @@ -905,7 +924,7 @@ async function showFirstInstallGuide(details) {
// Initialize on install
chrome.runtime.onInstalled.addListener(async (details) => {
await showFirstInstallGuide(details);
createContextMenus();
await createContextMenus();
await providerManager.load();
await loadMaxSteps();
await loadClarifyTimeout();
Expand All @@ -917,7 +936,7 @@ chrome.runtime.onInstalled.addListener(async (details) => {

// Also load on startup
chrome.runtime.onStartup?.addListener(async () => {
createContextMenus();
await createContextMenus();
await providerManager.load();
await loadMaxSteps();
await loadClarifyTimeout();
Expand All @@ -928,6 +947,10 @@ chrome.runtime.onStartup?.addListener(async () => {

// Listen for setting changes
chrome.storage.onChanged.addListener((changes) => {
if (changes.wbLocale) {
selectionShortcutLocale = normalizeSelectionShortcutLocale(changes.wbLocale.newValue);
createContextMenus().catch(() => {});
}
if (PROFILE_SYNC_DATA_KEYS.some((key) => changes[key])) profileSync.noteChanges(changes).catch(() => {});
if (changes.providers || changes.activeProvider || changes.helpImproveWebBrain) providerManager.load().catch(() => {});
if (changes.webbrainCloudBridgeEnabled || changes.webbrainCloudBridgeUrl) {
Expand Down Expand Up @@ -1267,10 +1290,10 @@ async function handleContextMenuAsk(info, tab) {
let text = '';
let selectionAction = '';
if (menuItemId === CONTEXT_MENU_GENERIC_ASK_ID) {
text = buildContextMenuPrompt(info.selectionText);
text = buildContextMenuPrompt(info.selectionText, selectionShortcutLocale);
} else if (menuItemId.startsWith(CONTEXT_MENU_ACTION_PREFIX)) {
selectionAction = normalizeSelectionAction(menuItemId.slice(CONTEXT_MENU_ACTION_PREFIX.length));
text = buildSelectionPrompt(info.selectionText, selectionAction);
text = buildSelectionPrompt(info.selectionText, selectionAction, '', selectionShortcutLocale);
} else if (menuItemId.startsWith(CONTEXT_MENU_TRANSLATE_PREFIX)) {
selectionAction = 'translate';
text = buildSelectionPrompt(info.selectionText, 'translate', '', menuItemId.slice(CONTEXT_MENU_TRANSLATE_PREFIX.length));
Expand Down Expand Up @@ -1300,6 +1323,11 @@ chrome.contextMenus?.onClicked?.addListener?.((info, tab) => {
handleContextMenuAsk(info, tab).catch(() => {});
});

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type !== 'WB_SELECTION_SHORTCUT_LOCALIZATION') return;
sendResponse({ ok: true, ...getSelectionShortcutLocalization(msg.locale) });
});

// Selection-shortcut clicks originate in a content script. Keep this listener
// synchronous until sidePanel.open() so Chrome preserves the originating user
// gesture; prompt recovery storage can finish afterward.
Expand Down
84 changes: 73 additions & 11 deletions src/chrome/src/content/selection-shortcut.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,20 @@
const STORAGE_KEY = 'selectionShortcutEnabled';
const LOCALE_STORAGE_KEY = 'wbLocale';
const SUBMIT_MESSAGE = 'WB_SELECTION_SHORTCUT_SUBMIT';
const LOCALIZATION_MESSAGE = 'WB_SELECTION_SHORTCUT_LOCALIZATION';
const GAP = 8;
const BUTTON_SIZE = 44;
const POPUP_WIDTH = 316;
const MAX_SELECTION_HIGHLIGHT_RECTS = 200;
const TRANSLATION_LANGUAGES = Object.freeze([
'en', 'es', 'fr', 'tr', 'zh', 'ru', 'uk', 'ar',
'ja', 'ko', 'id', 'th', 'ms', 'tl', 'pl', 'he',
'hi', 'pt', 'vi', 'bn', 'fa', 'nl', 'de',
]);
const LOCALIZATION_KEYS = Object.freeze([
'askSelection', 'openChat', 'summarize', 'explain', 'quiz',
'proofread', 'humanize', 'translate', 'translateTo', 'askAbout',
'askQuestion', 'sendQuestion', 'hideShortcut', 'sentManual', 'sendFailed',
]);

let enabled = true;
Expand All @@ -36,6 +43,8 @@
let question = null;
let sendButton = null;
let interfaceLanguage = resolveInterfaceLanguage('');
let localization = null;
let localizationRequestId = 0;
let toast = null;
let toastTimer = null;
let selectionTimer = null;
Expand Down Expand Up @@ -84,6 +93,57 @@
return isSupportedTranslationLanguage(browserLanguage) ? browserLanguage : 'en';
}

function normalizeLocalization(response) {
if (!response?.ok || !response.strings || typeof response.strings !== 'object') return null;
const strings = {};
for (const key of LOCALIZATION_KEYS) {
const value = response.strings[key];
if (typeof value !== 'string' || !value.trim()) return null;
strings[key] = value;
}
return {
locale: resolveInterfaceLanguage(response.locale),
dir: response.dir === 'rtl' ? 'rtl' : 'ltr',
strings,
};
}

function applyLocalization() {
if (!localization || !shadow) return;
const strings = localization.strings;
host.lang = localization.locale;
host.dir = localization.dir;
shortcut.setAttribute('aria-label', strings.askSelection);
shortcut.title = strings.askSelection;
popup.setAttribute('aria-label', strings.askSelection);
for (const action of ['summarize', 'explain', 'quiz', 'proofread', 'humanize', 'translate']) {
const button = shadow.querySelector(`[data-action="${action}"]`);
if (button) button.textContent = strings[action];
}
question.setAttribute('aria-label', strings.askQuestion);
question.placeholder = strings.askQuestion;
sendButton.setAttribute('aria-label', strings.sendQuestion);
const hideButton = shadow.querySelector('.hide');
if (hideButton) hideButton.textContent = strings.hideShortcut;
}

async function refreshLocalization(value) {
interfaceLanguage = resolveInterfaceLanguage(value);
const requestId = ++localizationRequestId;
try {
const response = await api.runtime.sendMessage({
type: LOCALIZATION_MESSAGE,
locale: interfaceLanguage,
});
if (requestId !== localizationRequestId) return;
const next = normalizeLocalization(response);
if (!next) return;
interfaceLanguage = next.locale;
localization = next;
applyLocalization();
} catch { /* English markup remains the offline fallback. */ }
}

function readSelection() {
if (!enabled || suppressed || submitting || isTextField(document.activeElement)) return null;
const selection = window.getSelection();
Expand Down Expand Up @@ -152,7 +212,7 @@
.actions { display:grid; gap:2px; }
.action,.hide {
width:100%; border:0; border-radius:10px; background:transparent;
color:var(--text); text-align:left; cursor:pointer;
color:var(--text); text-align:start; cursor:pointer;
}
.action { padding:10px 12px; font-size:15px; font-weight:550; }
.action:hover,.hide:hover { background:var(--hover); }
Expand Down Expand Up @@ -225,15 +285,15 @@
question = shadow.querySelector('textarea');
sendButton = shadow.querySelector('.send');
toast = shadow.querySelector('.toast');
applyLocalization();

shortcut.addEventListener('click', (event) => {
if (event.isTrusted && snapshot && !submitting) openPopup();
});
shadow.querySelectorAll('[data-action]').forEach((button) => {
button.addEventListener('click', (event) => {
if (!event.isTrusted) return;
if (button.dataset.action === 'translate') submitSelection('translate', '', interfaceLanguage);
else submitSelection(button.dataset.action);
submitSelection(button.dataset.action, '', interfaceLanguage);
});
});
question.addEventListener('input', () => {
Expand Down Expand Up @@ -384,16 +444,16 @@
action,
selectionText: snapshot.text,
question: action === 'custom' ? String(customQuestion).trim() : undefined,
language: action === 'translate' ? language : undefined,
language: action === 'custom' ? undefined : (language || interfaceLanguage),
};
submitting = true;
dismissSurface();
try {
const response = await api.runtime.sendMessage(request);
if (!response?.ok) throw new Error(response?.error || 'Selection request was not accepted.');
if (response.requiresManualOpen) showToast('Sent to WebBrain. Open the sidebar if it doesn’t start.');
if (response.requiresManualOpen) showToast(localization?.strings.sentManual || 'Sent to WebBrain. Open the sidebar if it does not start.');
} catch {
showToast('Couldn’t send to WebBrain. Use the right-click menu and choose “Ask WebBrain about this”.');
showToast(localization?.strings.sendFailed || 'Could not send to WebBrain. Try the right-click menu instead.');
} finally {
submitting = false;
}
Expand Down Expand Up @@ -449,12 +509,12 @@
enabled = changes[STORAGE_KEY].newValue !== false;
if (!enabled) destroySurface();
}
if (changes[LOCALE_STORAGE_KEY]) interfaceLanguage = resolveInterfaceLanguage(changes[LOCALE_STORAGE_KEY].newValue);
if (changes[LOCALE_STORAGE_KEY]) void refreshLocalization(changes[LOCALE_STORAGE_KEY].newValue);
});
Promise.resolve(api.storage.local.get({ [STORAGE_KEY]: true, [LOCALE_STORAGE_KEY]: '' }))
.then((stored) => {
enabled = stored?.[STORAGE_KEY] !== false;
interfaceLanguage = resolveInterfaceLanguage(stored?.[LOCALE_STORAGE_KEY]);
void refreshLocalization(stored?.[LOCALE_STORAGE_KEY]);
if (!enabled) destroySurface();
})
.catch(() => { enabled = true; });
Expand All @@ -463,9 +523,7 @@
window.__webbrainSelectionShortcut = {
refreshFromSelection,
openPopup,
submitPreset: (action) => action === 'translate'
? submitSelection('translate', '', interfaceLanguage)
: submitSelection(action),
submitPreset: (action) => submitSelection(action, '', interfaceLanguage),
submitCustom: (value) => submitSelection('custom', value),
hideShortcut: disableShortcut,
getState: () => ({
Expand All @@ -487,6 +545,10 @@
: null,
questionRect: popup && !popup.hidden ? question?.getBoundingClientRect().toJSON() || null : null,
questionValue: question?.value || '',
direction: host?.dir || 'ltr',
summarizeLabel: shadow?.querySelector('[data-action="summarize"]')?.textContent || '',
explainLabel: shadow?.querySelector('[data-action="explain"]')?.textContent || '',
quizLabel: shadow?.querySelector('[data-action="quiz"]')?.textContent || '',
}),
};
})();
34 changes: 30 additions & 4 deletions src/chrome/src/context-menu-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export const SELECTION_TRANSLATION_LANGUAGES = Object.freeze({
tl: 'Filipino',
pl: 'Polish',
he: 'Hebrew',
hi: 'Hindi',
pt: 'Portuguese',
vi: 'Vietnamese',
bn: 'Bengali',
fa: 'Persian',
nl: 'Dutch',
de: 'German',
});

const SELECTION_UNTRUSTED_PREAMBLE =
Expand All @@ -57,6 +64,22 @@ const SELECTION_SOURCE_GROUNDING =
'Use only the text inside the selection block as source material for this action. Do not substitute the screenshot, page title, surrounding page content, or earlier conversation. If the selection is insufficient, say so and ask the user to select more text.';
const CUSTOM_QUESTION_PREFIX = 'Please answer this user question about the selected text:\n';
const GENERIC_CONTEXT_MENU_INSTRUCTION = 'Please answer about this selected text from the current page.';

function responseLanguageInstruction(language) {
const languageCode = String(language || '').trim().toLowerCase();
const responseLanguage = Object.prototype.hasOwnProperty.call(SELECTION_TRANSLATION_LANGUAGES, languageCode)
? SELECTION_TRANSLATION_LANGUAGES[languageCode]
: '';
return responseLanguage ? ` Respond in ${responseLanguage}.` : '';
}

function stripResponseLanguageInstruction(instruction) {
for (const responseLanguage of Object.values(SELECTION_TRANSLATION_LANGUAGES)) {
const suffix = ` Respond in ${responseLanguage}.`;
if (instruction.endsWith(suffix)) return instruction.slice(0, -suffix.length);
}
return instruction;
}
// Match only prompts we generate: exact preamble + ctx- nonce box at the end.
// Legacy history may end at the store's exact truncation marker before the
// closing boundary. Do not rewrite arbitrary text that merely mentions these.
Expand Down Expand Up @@ -108,8 +131,9 @@ export function formatSelectionPromptForDisplay(promptText) {

if (instruction.startsWith(CUSTOM_QUESTION_PREFIX)) {
instruction = instruction.slice(CUSTOM_QUESTION_PREFIX.length).trim();
} else if (instruction === GENERIC_CONTEXT_MENU_INSTRUCTION) {
instruction = '';
} else {
instruction = stripResponseLanguageInstruction(instruction);
if (instruction === GENERIC_CONTEXT_MENU_INSTRUCTION) instruction = '';
}

const selectedBlock = `Selected text:\n${selection}`;
Expand All @@ -132,13 +156,15 @@ export function buildSelectionPrompt(selectionText, action, question = '', langu
: '';
if (!targetLanguage) return '';
instruction = `Translate this selected text into ${targetLanguage}. Preserve its meaning, tone, and formatting. Return only the translation unless a short note is necessary to resolve ambiguity.`;
} else if (instruction) {
instruction += responseLanguageInstruction(language);
}
if (!instruction) return '';
return wrapSelectedPageText(selectionText, instruction);
}

export function buildContextMenuPrompt(selectionText) {
return wrapSelectedPageText(selectionText, GENERIC_CONTEXT_MENU_INSTRUCTION);
export function buildContextMenuPrompt(selectionText, language = '') {
return wrapSelectedPageText(selectionText, GENERIC_CONTEXT_MENU_INSTRUCTION + responseLanguageInstruction(language));
Comment on lines +166 to +167
}

const CONTEXT_MENU_PENDING_PREFIX = 'contextMenuPrompt:';
Expand Down
Loading
Loading