From a0b122ecfd38a8aac65402cb09516aef82f21cac Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Mon, 3 Aug 2026 21:39:20 +0300 Subject: [PATCH 1/3] x --- docs/architecture.md | 6 +- docs/fr/skills.md | 10 ++ docs/skills.md | 9 ++ docs/zh-CN/skills.md | 6 + src/chrome/src/agent/agent.js | 54 +++++-- src/chrome/src/background.js | 24 ++- src/chrome/src/content/selection-shortcut.js | 1 + src/chrome/src/context-menu-storage.js | 17 ++ src/chrome/src/ui/context-menu-prompts.js | 14 +- src/chrome/src/ui/sidepanel.js | 7 + src/firefox/src/agent/agent.js | 54 +++++-- src/firefox/src/background.js | 24 ++- src/firefox/src/content/selection-shortcut.js | 1 + src/firefox/src/context-menu-storage.js | 17 ++ src/firefox/src/ui/context-menu-prompts.js | 14 +- src/firefox/src/ui/sidepanel.js | 7 + test/run.js | 153 +++++++++++++++++- 17 files changed, 383 insertions(+), 35 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 25b72d7ab..053d4370f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -314,6 +314,10 @@ new default IDs can still be migrated into existing installations. read-only article fallback is ready after a structured blocking `pageGate`. Webmail adapter runs preactivate the prompt-only Humanizer skill so a composed reply is rewritten without spending a model-visible `load_skill` hop. + Selected-text runs route on the shortcut action instead (`humanize`, or the + user's own typed request), which the durable selection scope carries across + follow-up turns: they suppress page context, so the adapter is unreliable + there, and their empty tool list leaves no `load_skill` to fall back on. - Tool exposure: `buildSkillToolDefinitions()` reads manifests only from active skills' Markdown bodies (never from Agent Skills frontmatter) and appends compatible schemas to `getToolsForMode(...)` at LLM-call time, respecting @@ -380,7 +384,7 @@ tracks as a successful video or hand ffmpeg work to the user. | Find, read, copy, or enter a code visible in browser email/message content | OTP / verification-code helper | Ask, Act, Dev | Prompt-only; after loading it guides existing page tools. | | Create and use a temporary mailbox for an unimportant signup | Disposable email (Mail.tm) | Act, Dev | Not shown to Ask. It may overlap with OTP during a verification flow, so both can be loaded. | | Read a YouTube transcript, fetch a blocked NYTimes article, or resolve/download supported public media | FreeSkillz.xyz | Ask, Act, Dev | Ask can load the skill but still cannot see its Act-only `download_public_media` tool. | -| Draft or rewrite an email reply, message, or post the user will send | Humanizer | Ask, Act, Dev | Prompt-only; preactivated on webmail adapters and otherwise routed by catalog. Returns final text only. | +| Draft or rewrite an email reply, message, or post the user will send | Humanizer | Ask, Act, Dev | Prompt-only; preactivated on webmail adapters and on the Humanize/typed selected-text shortcuts, otherwise routed by catalog. Returns final text only. | | Look up weather or a short forecast | Open-Meteo weather | Ask, Act, Dev | Read-only tools remain subject to their manifest filters. | | Find books, ISBNs, authors, or publication data | Open Library | Ask, Act, Dev | Read-only tools remain subject to their manifest filters. | | Search or summarize an encyclopedia topic | Wikipedia | Ask, Act, Dev | Read-only Wikipedia REST/Action API tools; results are untrusted. | diff --git a/docs/fr/skills.md b/docs/fr/skills.md index bd2a9be08..9afb9a10e 100644 --- a/docs/fr/skills.md +++ b/docs/fr/skills.md @@ -121,6 +121,16 @@ catalogue normal lorsque la demande porte sur la rédaction ou la réécriture d'un texte. Elle ne renvoie que le texte final et n'explique pas ses modifications, sauf si vous le demandez. +Sélectionnez du texte n'importe où et une entrée **Humanize** apparaît, dans la +fenêtre flottante comme dans le menu contextuel. Elle préactive la compétence +sur tous les sites, tout comme une demande que vous saisissez vous-même dans la +zone de sélection ; les tours suivants de la même conversation la conservent. +Les actions prédéfinies — Summarize, Explain, Quiz me, Proofread, Translate — ne +le font pas, car elles ne rédigent rien que vous alliez envoyer. Ce routage +existe parce qu'une exécution sur texte sélectionné n'embarque aucun outil : le +catalogue lui est inaccessible, et une compétence absente au démarrage ne peut +plus être chargée ensuite. + Elle ne réécrit que la prose destinée à un lecteur humain. Le contenu cité, les adresses, les codes, les prix, les valeurs de champs de formulaire et la formulation que vous fournissez mot pour mot restent intacts. diff --git a/docs/skills.md b/docs/skills.md index e7dbc02ee..7cbb6f285 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -104,6 +104,15 @@ through the catalog like anywhere else. Elsewhere it loads through the normal catalog when the request is about drafting or rewriting text. It returns only the final text; it does not report what it changed unless you ask. +Select text anywhere and you get a **Humanize** entry, both in the popup and in +the right-click menu. That entry preactivates the skill on any site, and so does +a request you type yourself into the selection box; later turns in the same +selected-text conversation keep it. The canned readers — Summarize, Explain, +Quiz me, Proofread, Translate — do not, since they compose nothing you are about +to send. This routing exists because a selected-text run carries no tools at +all: the catalog is out of reach, so a skill that is not loaded when the run +starts cannot be loaded later. + It rewrites only prose being composed for a human reader. Quoted material, addresses, codes, prices, form-field values, and wording you supplied verbatim are left alone. diff --git a/docs/zh-CN/skills.md b/docs/zh-CN/skills.md index cc1f5345e..66d280d03 100644 --- a/docs/zh-CN/skills.md +++ b/docs/zh-CN/skills.md @@ -89,6 +89,12 @@ Markdown 正文前会移除 frontmatter。在设置中输入的名称及 `webbra 通过目录加载。在其他站点,当请求涉及起草或改写文本时,它按正常目录加载。它只返回最终文本;除非你主动询问,否则 不会说明改动了什么。 +在任意页面选中文本后,浮窗和右键菜单里都会出现 **Humanize** 项。它会在任意站点预激活 +该技能;你自己在选区输入框里写下的请求同样如此,并且同一段选中文本会话的后续轮次也会 +保留它。预设的阅读类操作——Summarize、Explain、Quiz me、Proofread、Translate——不会 +预激活,因为它们并不撰写你要发出去的内容。这样路由是因为选中文本的运行完全不携带工具: +技能目录对它不可见,运行开始时没有加载的技能之后也无法再加载。 + 它只改写面向真人读者的正文。引用内容、地址、验证码、价格、表单字段值,以及你逐字提供 的措辞都保持不变。 diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 5ceefe2d2..ba3f5497b 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -68,7 +68,11 @@ import { } from './workflows.js'; import { mergeRedactionFrameRegions, mapRegionsToImage, pixelateDataUrl } from './screenshot-redaction.js'; import { buildTrustedRuntimeContext, stripTrustedRuntimeContext } from './runtime-context.js'; -import { SELECTION_ONLY_SOURCE_GROUNDING } from '../context-menu-storage.js'; +import { + isSelectionProseAction, + normalizeSelectionAction, + SELECTION_ONLY_SOURCE_GROUNDING, +} from '../context-menu-storage.js'; import { resolveSavedDownload } from '../download-result.js'; import { executeChromeWebStoreSkillTool, isTrustedChromeWebStoreSkillTool } from '../chrome-web-store-release.js'; import { chromeProtectedPageFailure, isChromeProtectedPageDomTool } from '../chrome-protected-pages.js'; @@ -6726,6 +6730,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d excludedFingerprints: Array.isArray(entry.selectionGroundingScope.excludedFingerprints) ? entry.selectionGroundingScope.excludedFingerprints.filter(value => typeof value === 'string') : [], + action: normalizeSelectionAction(entry.selectionGroundingScope.action), }); } if ( @@ -10105,8 +10110,22 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } } - _preactivateHumanizerSkillForRun(tabId, mode) { - if (!HUMANIZER_SKILL_SITE_ADAPTERS.has(this._activeSkillSiteAdapter(tabId))) return false; + /** + * Two independent routes, because they fail differently. + * + * An ordinary run matches the webmail adapter and can still reach the skill + * through load_skill if the match misses. A selected-text run cannot: it + * carries no tools at all, and it suppresses page context, which leaves + * lastSeenAdapter stale or empty. So it routes on the shortcut action the + * user picked — Humanize, or their own typed request — which the durable + * selection scope keeps across follow-up turns. The canned readers + * (summarize, explain, quiz, translate, proofread) produce no prose to send + * and would only pay for the skill body in tokens. + */ + _preactivateHumanizerSkillForRun(tabId, mode, { selectionOnly = false, selectionAction = '' } = {}) { + if (selectionOnly + ? !isSelectionProseAction(selectionAction) + : !HUMANIZER_SKILL_SITE_ADAPTERS.has(this._activeSkillSiteAdapter(tabId))) return false; const tier = this._resolvePromptTier(); if (tier === 'compact') return false; const owner = this._eligibleSkills(mode, tier).find((skill) => skill.id === 'humanizer'); @@ -13278,6 +13297,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d excludedFingerprints: messages.slice(1).map(message => this._selectionGroundingMessageFingerprint(message) ), + // Only the opening turn carries the shortcut action. Store it on the + // scope so "now make it warmer" is still recognizable as the writing + // flow the user started, without re-trusting a resent field. + action: normalizeSelectionAction(runOptions?.selectionAction), }; this.selectionGroundingScopes.set(tabId, scope); } else if ( @@ -13291,6 +13314,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ...runOptions, sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING, selectionGroundingScopeStarted: explicitSelection, + selectionAction: normalizeSelectionAction(scope?.action), }; } @@ -19803,10 +19827,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d sourceBoundMessagesAtTrim = new Set(rawMessages); sourceBoundTrimmedMessages = this._emergencyTrimModelCopy(currentModelMessages); }; - if (!selectionOnly) { - this._preactivateNyTimesSkillForRun(tabId, mode); - this._preactivateHumanizerSkillForRun(tabId, mode); - } + // NYTimes preactivation only buys a fetch tool, which a source-bound run + // cannot call; Humanizer is prompt-only and a selected-text writing + // shortcut has no other way to load it, so it runs on both paths. + if (!selectionOnly) this._preactivateNyTimesSkillForRun(tabId, mode); + this._preactivateHumanizerSkillForRun(tabId, mode, { + selectionOnly, + selectionAction: runOptions?.selectionAction, + }); const provider = this.providerManager.getActive(); @@ -20582,10 +20610,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d sourceBoundMessagesAtTrim = new Set(rawMessages); sourceBoundTrimmedMessages = this._emergencyTrimModelCopy(currentModelMessages); }; - if (!selectionOnly) { - this._preactivateNyTimesSkillForRun(tabId, mode); - this._preactivateHumanizerSkillForRun(tabId, mode); - } + // NYTimes preactivation only buys a fetch tool, which a source-bound run + // cannot call; Humanizer is prompt-only and a selected-text writing + // shortcut has no other way to load it, so it runs on both paths. + if (!selectionOnly) this._preactivateNyTimesSkillForRun(tabId, mode); + this._preactivateHumanizerSkillForRun(tabId, mode, { + selectionOnly, + selectionAction: runOptions?.selectionAction, + }); const provider = this.providerManager.getActive(); diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 66226f604..389125f1a 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -35,6 +35,7 @@ import { SELECTION_TRANSLATION_LANGUAGES, buildContextMenuPrompt, buildSelectionPrompt, + normalizeSelectionAction, createContextMenuStorage, } from './context-menu-storage.js'; import { createTabChatHandoffCoordinator } from './ui/tab-chat-persistence.js'; @@ -216,6 +217,7 @@ function createContextMenus() { ['explain', 'Explain'], ['quiz', 'Quiz me'], ['proofread', 'Proofread'], + ['humanize', 'Humanize'], ]) { create({ id: `${CONTEXT_MENU_ACTION_PREFIX}${action}`, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title, contexts: ['selection'] }); } @@ -1208,11 +1210,14 @@ async function handleContextMenuAsk(info, tab) { } let text = ''; + let selectionAction = ''; if (menuItemId === CONTEXT_MENU_GENERIC_ASK_ID) { text = buildContextMenuPrompt(info.selectionText); } else if (menuItemId.startsWith(CONTEXT_MENU_ACTION_PREFIX)) { - text = buildSelectionPrompt(info.selectionText, menuItemId.slice(CONTEXT_MENU_ACTION_PREFIX.length)); + selectionAction = normalizeSelectionAction(menuItemId.slice(CONTEXT_MENU_ACTION_PREFIX.length)); + text = buildSelectionPrompt(info.selectionText, selectionAction); } else if (menuItemId.startsWith(CONTEXT_MENU_TRANSLATE_PREFIX)) { + selectionAction = 'translate'; text = buildSelectionPrompt(info.selectionText, 'translate', '', menuItemId.slice(CONTEXT_MENU_TRANSLATE_PREFIX.length)); } if (!text) return; @@ -1222,6 +1227,7 @@ async function handleContextMenuAsk(info, tab) { tabId: tab.id, text, sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING, + ...(selectionAction ? { selectionAction } : {}), createdAt: Date.now(), }; @@ -1245,6 +1251,7 @@ chrome.contextMenus?.onClicked?.addListener?.((info, tab) => { chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg?.type !== 'WB_SELECTION_SHORTCUT_SUBMIT') return; const tab = sender?.tab; + const selectionAction = normalizeSelectionAction(msg.action); const text = buildSelectionPrompt(msg.selectionText, msg.action, msg.question, msg.language); if (!tab?.id || !text) { sendResponse({ ok: false, queued: false, requiresManualOpen: false, error: 'Invalid selection shortcut request.' }); @@ -1256,6 +1263,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { tabId: tab.id, text, sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING, + ...(selectionAction ? { selectionAction } : {}), createdAt: Date.now(), }; @@ -2274,7 +2282,12 @@ async function handleMessage(msg, sender) { ...(msg.recommendedAction ? { recommendedAction: msg.recommendedAction } : {}), ...(msg.foreground ? { foreground: true } : {}), ...(msg.sourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING - ? { sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING } + ? { + sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING, + ...(normalizeSelectionAction(msg.selectionAction) + ? { selectionAction: normalizeSelectionAction(msg.selectionAction) } + : {}), + } : {}), locale: msg.locale, intentFailureMessage: msg.intentFailureMessage, @@ -2394,7 +2407,12 @@ async function handleMessage(msg, sender) { ...(msg.recommendedAction ? { recommendedAction: msg.recommendedAction } : {}), ...(msg.foreground ? { foreground: true } : {}), ...(msg.sourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING - ? { sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING } + ? { + sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING, + ...(normalizeSelectionAction(msg.selectionAction) + ? { selectionAction: normalizeSelectionAction(msg.selectionAction) } + : {}), + } : {}), locale: msg.locale, intentFailureMessage: msg.intentFailureMessage, diff --git a/src/chrome/src/content/selection-shortcut.js b/src/chrome/src/content/selection-shortcut.js index 5a0909964..cc72b8bd3 100644 --- a/src/chrome/src/content/selection-shortcut.js +++ b/src/chrome/src/content/selection-shortcut.js @@ -201,6 +201,7 @@ +
diff --git a/src/chrome/src/context-menu-storage.js b/src/chrome/src/context-menu-storage.js index 9fa6a2a38..a836c1452 100644 --- a/src/chrome/src/context-menu-storage.js +++ b/src/chrome/src/context-menu-storage.js @@ -8,8 +8,25 @@ export const SELECTION_SHORTCUT_ACTIONS = Object.freeze({ explain: 'Explain this selected text in plain language.', quiz: 'Quiz me on this selected text. Ask one question at a time and wait for my answer.', proofread: 'Proofread this selected text. Identify errors and provide a corrected version while preserving its meaning and tone.', + humanize: 'Rewrite this selected text so it reads as human writing rather than AI output. Keep every claim, the language, and the author\'s intent; return only the rewritten text.', }); +// Selected-text runs carry no tools, so `load_skill` cannot rescue a writing +// request mid-run: a prose skill either rides in at run start or never. These +// are the shortcut actions whose output is prose the user will send, kept as +// structured ids so no downstream code has to pattern-match prompt wording. +export const SELECTION_PROSE_ACTIONS = Object.freeze(['humanize', 'custom']); + +export function normalizeSelectionAction(value) { + const action = String(value == null ? '' : value).trim(); + if (action === 'custom' || action === 'translate') return action; + return Object.prototype.hasOwnProperty.call(SELECTION_SHORTCUT_ACTIONS, action) ? action : ''; +} + +export function isSelectionProseAction(value) { + return SELECTION_PROSE_ACTIONS.includes(normalizeSelectionAction(value)); +} + // Structured provenance for selected-text shortcuts. Keep this independent // from localized/user-visible prompt wording so downstream code never has to // infer the source boundary with regexes or language-specific keywords. diff --git a/src/chrome/src/ui/context-menu-prompts.js b/src/chrome/src/ui/context-menu-prompts.js index ee6c42ff7..eb9f498c6 100644 --- a/src/chrome/src/ui/context-menu-prompts.js +++ b/src/chrome/src/ui/context-menu-prompts.js @@ -3,7 +3,7 @@ * The Chrome and Firefox copies of this file are identical — edit both together. */ -import { SELECTION_ONLY_SOURCE_GROUNDING } from '../context-menu-storage.js'; +import { SELECTION_ONLY_SOURCE_GROUNDING, normalizeSelectionAction } from '../context-menu-storage.js'; export function createContextMenuPromptHandler({ getCurrentTabId, @@ -36,7 +36,16 @@ export function createContextMenuPromptHandler({ const sourceGrounding = payload?.sourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING ? SELECTION_ONLY_SOURCE_GROUNDING : null; - return { id, tabId, text, ...(sourceGrounding ? { sourceGrounding } : {}) }; + // Only a source-bound prompt has a shortcut action to report; anything + // else would be an unattributed id riding into the run options. + const selectionAction = sourceGrounding ? normalizeSelectionAction(payload?.selectionAction) : ''; + return { + id, + tabId, + text, + ...(sourceGrounding ? { sourceGrounding } : {}), + ...(selectionAction ? { selectionAction } : {}), + }; } function contextMenuPromptMatchesCurrentTab(payload) { @@ -206,6 +215,7 @@ export function createContextMenuPromptHandler({ rejectedClaim = result || { reason: 'claim-lost' }; }, ...(payload.sourceGrounding ? { sourceGrounding: payload.sourceGrounding } : {}), + ...(payload.selectionAction ? { selectionAction: payload.selectionAction } : {}), }); } catch { /* storage recovery can retry the prompt later */ } runningContextMenuPromptId = null; diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 511566ba3..3e3d00adf 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -11,6 +11,7 @@ import { buildRecommendedActions, shouldShowRecommendedActions } from './recomme import { createContextMenuPromptHandler } from './context-menu-prompts.js'; import { formatSelectionPromptForDisplay, + normalizeSelectionAction, SELECTION_ONLY_SOURCE_GROUNDING, } from '../context-menu-storage.js'; import { deleteChatHistoryRecord, saveChatHistoryRecord } from './chat-history-store.js'; @@ -7204,6 +7205,12 @@ async function sendMessage(extraChatParams = {}) { : null; delete chatExtraParams.sourceGrounding; if (sourceGrounding) chatExtraParams.sourceGrounding = sourceGrounding; + // The shortcut action rides along only on the turn that started the scope. + // Later turns have none: the agent reads the action off the durable + // selected-text scope instead of trusting a resent field. + const selectionAction = sourceGrounding ? normalizeSelectionAction(chatExtraParams.selectionAction) : ''; + delete chatExtraParams.selectionAction; + if (selectionAction) chatExtraParams.selectionAction = selectionAction; await waitForVisibleSidePanelStateRefresh(); if (contextMenuClaimOwned && (document.visibilityState === 'hidden' diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 9b452a630..510ce77e3 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -69,7 +69,11 @@ import { } from './workflows.js'; import { mergeRedactionFrameRegions, mapRegionsToImage, pixelateDataUrl } from './screenshot-redaction.js'; import { buildTrustedRuntimeContext, stripTrustedRuntimeContext } from './runtime-context.js'; -import { SELECTION_ONLY_SOURCE_GROUNDING } from '../context-menu-storage.js'; +import { + isSelectionProseAction, + normalizeSelectionAction, + SELECTION_ONLY_SOURCE_GROUNDING, +} from '../context-menu-storage.js'; import { firefoxHostPermissionFailure, firefoxRestrictedDomainFailure } from '../firefox-restricted-domains.js'; import { filenameInConfiguredDownloadDirectory } from '../download-directory.js'; import { resolveSavedDownload } from '../download-result.js'; @@ -831,6 +835,7 @@ export class Agent extends LoopDetector { excludedFingerprints: Array.isArray(entry.selectionGroundingScope.excludedFingerprints) ? entry.selectionGroundingScope.excludedFingerprints.filter(value => typeof value === 'string') : [], + action: normalizeSelectionAction(entry.selectionGroundingScope.action), }); } if ( @@ -8867,8 +8872,22 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } } - _preactivateHumanizerSkillForRun(tabId, mode) { - if (!HUMANIZER_SKILL_SITE_ADAPTERS.has(this._activeSkillSiteAdapter(tabId))) return false; + /** + * Two independent routes, because they fail differently. + * + * An ordinary run matches the webmail adapter and can still reach the skill + * through load_skill if the match misses. A selected-text run cannot: it + * carries no tools at all, and it suppresses page context, which leaves + * lastSeenAdapter stale or empty. So it routes on the shortcut action the + * user picked — Humanize, or their own typed request — which the durable + * selection scope keeps across follow-up turns. The canned readers + * (summarize, explain, quiz, translate, proofread) produce no prose to send + * and would only pay for the skill body in tokens. + */ + _preactivateHumanizerSkillForRun(tabId, mode, { selectionOnly = false, selectionAction = '' } = {}) { + if (selectionOnly + ? !isSelectionProseAction(selectionAction) + : !HUMANIZER_SKILL_SITE_ADAPTERS.has(this._activeSkillSiteAdapter(tabId))) return false; const tier = this._resolvePromptTier(); if (tier === 'compact') return false; const owner = this._eligibleSkills(mode, tier).find((skill) => skill.id === 'humanizer'); @@ -12008,6 +12027,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d excludedFingerprints: messages.slice(1).map(message => this._selectionGroundingMessageFingerprint(message) ), + // Only the opening turn carries the shortcut action. Store it on the + // scope so "now make it warmer" is still recognizable as the writing + // flow the user started, without re-trusting a resent field. + action: normalizeSelectionAction(runOptions?.selectionAction), }; this.selectionGroundingScopes.set(tabId, scope); } else if ( @@ -12021,6 +12044,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ...runOptions, sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING, selectionGroundingScopeStarted: explicitSelection, + selectionAction: normalizeSelectionAction(scope?.action), }; } @@ -14981,10 +15005,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d sourceBoundMessagesAtTrim = new Set(rawMessages); sourceBoundTrimmedMessages = this._emergencyTrimModelCopy(currentModelMessages); }; - if (!selectionOnly) { - this._preactivateNyTimesSkillForRun(tabId, mode); - this._preactivateHumanizerSkillForRun(tabId, mode); - } + // NYTimes preactivation only buys a fetch tool, which a source-bound run + // cannot call; Humanizer is prompt-only and a selected-text writing + // shortcut has no other way to load it, so it runs on both paths. + if (!selectionOnly) this._preactivateNyTimesSkillForRun(tabId, mode); + this._preactivateHumanizerSkillForRun(tabId, mode, { + selectionOnly, + selectionAction: runOptions?.selectionAction, + }); const provider = this.providerManager.getActive(); @@ -15735,10 +15763,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d sourceBoundMessagesAtTrim = new Set(rawMessages); sourceBoundTrimmedMessages = this._emergencyTrimModelCopy(currentModelMessages); }; - if (!selectionOnly) { - this._preactivateNyTimesSkillForRun(tabId, mode); - this._preactivateHumanizerSkillForRun(tabId, mode); - } + // NYTimes preactivation only buys a fetch tool, which a source-bound run + // cannot call; Humanizer is prompt-only and a selected-text writing + // shortcut has no other way to load it, so it runs on both paths. + if (!selectionOnly) this._preactivateNyTimesSkillForRun(tabId, mode); + this._preactivateHumanizerSkillForRun(tabId, mode, { + selectionOnly, + selectionAction: runOptions?.selectionAction, + }); const provider = this.providerManager.getActive(); diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index edd6e3057..68d1fba1e 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -33,6 +33,7 @@ import { SELECTION_TRANSLATION_LANGUAGES, buildContextMenuPrompt, buildSelectionPrompt, + normalizeSelectionAction, createContextMenuStorage, } from './context-menu-storage.js'; import { createTabChatHandoffCoordinator } from './ui/tab-chat-persistence.js'; @@ -183,6 +184,7 @@ function createContextMenus() { ['explain', 'Explain'], ['quiz', 'Quiz me'], ['proofread', 'Proofread'], + ['humanize', 'Humanize'], ]) { createItem({ id: `${CONTEXT_MENU_ACTION_PREFIX}${action}`, parentId: CONTEXT_MENU_ASK_SELECTION_ID, title, contexts: ['selection'] }); } @@ -1074,11 +1076,14 @@ async function handleContextMenuAsk(info, tab) { } let text = ''; + let selectionAction = ''; if (menuItemId === CONTEXT_MENU_GENERIC_ASK_ID) { text = buildContextMenuPrompt(info.selectionText); } else if (menuItemId.startsWith(CONTEXT_MENU_ACTION_PREFIX)) { - text = buildSelectionPrompt(info.selectionText, menuItemId.slice(CONTEXT_MENU_ACTION_PREFIX.length)); + selectionAction = normalizeSelectionAction(menuItemId.slice(CONTEXT_MENU_ACTION_PREFIX.length)); + text = buildSelectionPrompt(info.selectionText, selectionAction); } else if (menuItemId.startsWith(CONTEXT_MENU_TRANSLATE_PREFIX)) { + selectionAction = 'translate'; text = buildSelectionPrompt(info.selectionText, 'translate', '', menuItemId.slice(CONTEXT_MENU_TRANSLATE_PREFIX.length)); } if (!text) return; @@ -1088,6 +1093,7 @@ async function handleContextMenuAsk(info, tab) { tabId: tab.id, text, sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING, + ...(selectionAction ? { selectionAction } : {}), createdAt: Date.now(), }; @@ -1111,6 +1117,7 @@ getContextMenuApi()?.onClicked?.addListener?.((info, tab) => { browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg?.type !== 'WB_SELECTION_SHORTCUT_SUBMIT') return; const tab = sender?.tab; + const selectionAction = normalizeSelectionAction(msg.action); const text = buildSelectionPrompt(msg.selectionText, msg.action, msg.question, msg.language); if (!tab?.id || !text) { sendResponse({ ok: false, queued: false, requiresManualOpen: true, error: 'Invalid selection shortcut request.' }); @@ -1122,6 +1129,7 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { tabId: tab.id, text, sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING, + ...(selectionAction ? { selectionAction } : {}), createdAt: Date.now(), }; @@ -1989,7 +1997,12 @@ async function handleMessage(msg, sender) { ...(msg.recommendedAction ? { recommendedAction: msg.recommendedAction } : {}), ...(msg.foreground ? { foreground: true } : {}), ...(msg.sourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING - ? { sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING } + ? { + sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING, + ...(normalizeSelectionAction(msg.selectionAction) + ? { selectionAction: normalizeSelectionAction(msg.selectionAction) } + : {}), + } : {}), locale: msg.locale, intentFailureMessage: msg.intentFailureMessage, @@ -2104,7 +2117,12 @@ async function handleMessage(msg, sender) { ...(msg.recommendedAction ? { recommendedAction: msg.recommendedAction } : {}), ...(msg.foreground ? { foreground: true } : {}), ...(msg.sourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING - ? { sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING } + ? { + sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING, + ...(normalizeSelectionAction(msg.selectionAction) + ? { selectionAction: normalizeSelectionAction(msg.selectionAction) } + : {}), + } : {}), locale: msg.locale, intentFailureMessage: msg.intentFailureMessage, diff --git a/src/firefox/src/content/selection-shortcut.js b/src/firefox/src/content/selection-shortcut.js index 5a0909964..cc72b8bd3 100644 --- a/src/firefox/src/content/selection-shortcut.js +++ b/src/firefox/src/content/selection-shortcut.js @@ -201,6 +201,7 @@ +
diff --git a/src/firefox/src/context-menu-storage.js b/src/firefox/src/context-menu-storage.js index a2112c262..fe071e60d 100644 --- a/src/firefox/src/context-menu-storage.js +++ b/src/firefox/src/context-menu-storage.js @@ -8,8 +8,25 @@ export const SELECTION_SHORTCUT_ACTIONS = Object.freeze({ explain: 'Explain this selected text in plain language.', quiz: 'Quiz me on this selected text. Ask one question at a time and wait for my answer.', proofread: 'Proofread this selected text. Identify errors and provide a corrected version while preserving its meaning and tone.', + humanize: 'Rewrite this selected text so it reads as human writing rather than AI output. Keep every claim, the language, and the author\'s intent; return only the rewritten text.', }); +// Selected-text runs carry no tools, so `load_skill` cannot rescue a writing +// request mid-run: a prose skill either rides in at run start or never. These +// are the shortcut actions whose output is prose the user will send, kept as +// structured ids so no downstream code has to pattern-match prompt wording. +export const SELECTION_PROSE_ACTIONS = Object.freeze(['humanize', 'custom']); + +export function normalizeSelectionAction(value) { + const action = String(value == null ? '' : value).trim(); + if (action === 'custom' || action === 'translate') return action; + return Object.prototype.hasOwnProperty.call(SELECTION_SHORTCUT_ACTIONS, action) ? action : ''; +} + +export function isSelectionProseAction(value) { + return SELECTION_PROSE_ACTIONS.includes(normalizeSelectionAction(value)); +} + // Structured provenance for selected-text shortcuts. Keep this independent // from localized/user-visible prompt wording so downstream code never has to // infer the source boundary with regexes or language-specific keywords. diff --git a/src/firefox/src/ui/context-menu-prompts.js b/src/firefox/src/ui/context-menu-prompts.js index ee6c42ff7..eb9f498c6 100644 --- a/src/firefox/src/ui/context-menu-prompts.js +++ b/src/firefox/src/ui/context-menu-prompts.js @@ -3,7 +3,7 @@ * The Chrome and Firefox copies of this file are identical — edit both together. */ -import { SELECTION_ONLY_SOURCE_GROUNDING } from '../context-menu-storage.js'; +import { SELECTION_ONLY_SOURCE_GROUNDING, normalizeSelectionAction } from '../context-menu-storage.js'; export function createContextMenuPromptHandler({ getCurrentTabId, @@ -36,7 +36,16 @@ export function createContextMenuPromptHandler({ const sourceGrounding = payload?.sourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING ? SELECTION_ONLY_SOURCE_GROUNDING : null; - return { id, tabId, text, ...(sourceGrounding ? { sourceGrounding } : {}) }; + // Only a source-bound prompt has a shortcut action to report; anything + // else would be an unattributed id riding into the run options. + const selectionAction = sourceGrounding ? normalizeSelectionAction(payload?.selectionAction) : ''; + return { + id, + tabId, + text, + ...(sourceGrounding ? { sourceGrounding } : {}), + ...(selectionAction ? { selectionAction } : {}), + }; } function contextMenuPromptMatchesCurrentTab(payload) { @@ -206,6 +215,7 @@ export function createContextMenuPromptHandler({ rejectedClaim = result || { reason: 'claim-lost' }; }, ...(payload.sourceGrounding ? { sourceGrounding: payload.sourceGrounding } : {}), + ...(payload.selectionAction ? { selectionAction: payload.selectionAction } : {}), }); } catch { /* storage recovery can retry the prompt later */ } runningContextMenuPromptId = null; diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index ae6fa824f..a9b58cb0f 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -11,6 +11,7 @@ import { buildRecommendedActions, shouldShowRecommendedActions } from './recomme import { createContextMenuPromptHandler } from './context-menu-prompts.js'; import { formatSelectionPromptForDisplay, + normalizeSelectionAction, SELECTION_ONLY_SOURCE_GROUNDING, } from '../context-menu-storage.js'; import { deleteChatHistoryRecord, saveChatHistoryRecord } from './chat-history-store.js'; @@ -6946,6 +6947,12 @@ async function sendMessage(extraChatParams = {}) { : null; delete chatExtraParams.sourceGrounding; if (sourceGrounding) chatExtraParams.sourceGrounding = sourceGrounding; + // The shortcut action rides along only on the turn that started the scope. + // Later turns have none: the agent reads the action off the durable + // selected-text scope instead of trusting a resent field. + const selectionAction = sourceGrounding ? normalizeSelectionAction(chatExtraParams.selectionAction) : ''; + delete chatExtraParams.selectionAction; + if (selectionAction) chatExtraParams.selectionAction = selectionAction; await waitForVisibleSidePanelStateRefresh(); if (contextMenuClaimOwned && (document.visibilityState === 'hidden' diff --git a/test/run.js b/test/run.js index 7db9938b2..5a2d8481d 100644 --- a/test/run.js +++ b/test/run.js @@ -13206,7 +13206,7 @@ test('NYTimes runs preactivate the adapter-scoped fallback without weakening ski } }); -test('mail runs preactivate the Humanizer skill without widening it to other sites or Compact', () => { +test('mail and selected-text runs preactivate the Humanizer skill without widening it to other sites or Compact', () => { for (const [label, prefix, AgentClass] of [ ['chrome', 'src/chrome', AgentCh], ['firefox', 'src/firefox', AgentFx], @@ -13243,16 +13243,149 @@ test('mail runs preactivate the Humanizer skill without widening it to other sit agent.lastSeenAdapter.set(tabId, ''); assert.equal(agent._preactivateHumanizerSkillForRun(tabId, 'act'), false, `${label}: absent adapter preactivated Humanizer`); + // Selected-text runs suppress page context, so the adapter is empty or + // stale; they route on the shortcut action instead, and their empty tool + // list means load_skill can never load the skill afterwards. + for (const action of ['humanize', 'custom']) { + assert.equal( + agent._preactivateHumanizerSkillForRun(tabId, 'ask', { selectionOnly: true, selectionAction: action }), + true, + `${label}: ${action} selected-text run did not preactivate Humanizer` + ); + assert.ok(agent.activeSkillIds.get(tabId)?.has('humanizer'), `${label}: ${action} active skill id missing`); + agent._resetActiveSkillsForRun(tabId, { refreshPrompt: false }); + } + + // The canned readers compose nothing the user sends, so they must not pay + // for the skill body — and neither must an unknown or absent action. + for (const action of ['summarize', 'explain', 'quiz', 'translate', 'proofread', 'load_skill', '']) { + assert.equal( + agent._preactivateHumanizerSkillForRun(tabId, 'ask', { selectionOnly: true, selectionAction: action }), + false, + `${label}: ${action || '(absent)'} selected-text run preactivated Humanizer` + ); + } + assert.equal(agent.activeSkillIds.has(tabId), false, `${label}: non-writing selected-text run retained an active skill`); + // A user who removes the default skill must not get it back through preactivation. agent.lastSeenAdapter.set(tabId, 'gmail'); agent.setCustomSkills([]); assert.equal(agent._preactivateHumanizerSkillForRun(tabId, 'act'), false, `${label}: removed skill was preactivated`); + assert.equal( + agent._preactivateHumanizerSkillForRun(tabId, 'ask', { selectionOnly: true, selectionAction: 'humanize' }), + false, + `${label}: removed skill was preactivated for a selected-text run` + ); const compact = new AgentClass({ getActive: () => ({ promptTier: 'compact' }) }); compact.setCustomSkills([packagedHumanizerRecord(prefix)]); compact.conversationModes.set(tabId, 'act'); compact.lastSeenAdapter.set(tabId, 'gmail'); assert.equal(compact._preactivateHumanizerSkillForRun(tabId, 'act'), false, `${label}: Compact preactivated a skill`); + assert.equal( + compact._preactivateHumanizerSkillForRun(tabId, 'ask', { selectionOnly: true, selectionAction: 'humanize' }), + false, + `${label}: Compact preactivated a skill for a selected-text run` + ); + } +}); + +test('selected-text runs carry the Humanizer body into the tool-free request without the page skill', async () => { + for (const [buildIndex, [label, prefix, AgentClass, buildSelectionPrompt, sourceGrounding]] of [ + ['chrome', 'src/chrome', AgentCh, buildSelectionPromptCh, SELECTION_ONLY_SOURCE_GROUNDING_CH], + ['firefox', 'src/firefox', AgentFx, buildSelectionPromptFx, SELECTION_ONLY_SOURCE_GROUNDING_FX], + ].entries()) { + for (const [pathIndex, streaming] of [false, true].entries()) { + const requests = []; + const requestOptions = []; + const provider = { + supportsTools: true, + supportsVision: false, + promptTier: 'full', + contextWindow: 128000, + model: 'test-model', + name: 'test-provider', + chat: async (messages, options) => { + requests.push(messages); + requestOptions.push(options); + return { content: 'Rewritten reply.', toolCalls: null }; + }, + async *chatStream(messages, options) { + requests.push(messages); + requestOptions.push(options); + yield { type: 'text', content: 'Rewritten reply.' }; + yield { type: 'done' }; + }, + }; + const agent = new AgentClass({ + getActive: () => provider, + getVisionProvider: async () => null, + }); + const tabId = 4960 + (buildIndex * 10) + pathIndex; + agent.setCustomSkills([packagedHumanizerRecord(prefix)]); + agent.conversationModes.set(tabId, 'ask'); + agent.conversations.set(tabId, [{ role: 'system', content: agent._buildSystemPrompt('ask', tabId) }]); + agent.maxSteps = 2; + agent._hydrate = async () => {}; + agent._enrichUserMessageWithCurrentPage = async (_tabId, _history, content) => ({ role: 'user', content }); + // The page-scoped skill stays out: a source-bound run cannot call its + // fetch tool, while Humanizer only adds prompt text. + agent._preactivateNyTimesSkillForRun = () => { + throw new Error('selection-only run must not activate a page-specific skill'); + }; + agent._startTraceRun = async () => null; + agent._endTraceRun = () => {}; + agent._persist = () => {}; + agent._checkCostAllowance = async () => null; + agent._recordCostUsage = async () => null; + + const prompt = buildSelectionPrompt('Circling back to touch base on the deliverable.', 'humanize'); + const runOptions = { sourceGrounding, selectionAction: 'humanize' }; + const final = streaming + ? await agent.processMessageStream(tabId, prompt, () => {}, 'ask', runOptions) + : await agent.processMessage(tabId, prompt, () => {}, 'ask', [], runOptions); + const path = `${label} ${streaming ? 'streaming' : 'non-streaming'}`; + + assert.equal(final, 'Rewritten reply.', `${path}: final mismatch`); + assert.equal(requests.length, 1, `${path}: expected one model request`); + assert.equal(requestOptions[0]?.tools, undefined, `${path}: selected-text request must stay tool-free`); + assert.match( + String(requests[0][0]?.content), + /Humanizer/, + `${path}: preactivated skill body missing from the selected-text request` + ); + assert.equal(agent.activeSkillIds.has(tabId), false, `${path}: run-scoped activation should not outlive the run`); + + // A grounded follow-up ("make it warmer") is the same writing flow. + const followUp = streaming + ? await agent.processMessageStream(tabId, 'Make it warmer.', () => {}, 'ask') + : await agent.processMessage(tabId, 'Make it warmer.', () => {}, 'ask'); + assert.equal(followUp, 'Rewritten reply.', `${path}: grounded follow-up final mismatch`); + assert.equal(requests.length, 2, `${path}: follow-up should make one additional model request`); + assert.equal(requestOptions[1]?.tools, undefined, `${path}: grounded follow-up must remain tool-free`); + assert.match( + String(requests[1][0]?.content), + /Humanizer/, + `${path}: grounded follow-up lost the skill body` + ); + + // A canned reader shortcut on the same tab pays nothing for the skill. + const readerTabId = tabId + 100; + agent.conversationModes.set(readerTabId, 'ask'); + agent.conversations.set(readerTabId, [{ role: 'system', content: agent._buildSystemPrompt('ask', readerTabId) }]); + const readerPrompt = buildSelectionPrompt('Circling back to touch base on the deliverable.', 'summarize'); + const readerRunOptions = { sourceGrounding, selectionAction: 'summarize' }; + const readerFinal = streaming + ? await agent.processMessageStream(readerTabId, readerPrompt, () => {}, 'ask', readerRunOptions) + : await agent.processMessage(readerTabId, readerPrompt, () => {}, 'ask', [], readerRunOptions); + assert.equal(readerFinal, 'Rewritten reply.', `${path}: summarize shortcut final mismatch`); + assert.equal(requests.length, 3, `${path}: summarize shortcut should make one additional model request`); + assert.doesNotMatch( + String(requests[2][0]?.content), + /Humanizer/, + `${path}: summarize shortcut should not carry the skill body` + ); + } } }); @@ -23281,12 +23414,28 @@ test('selection shortcut is shipped, enabled by default, and keeps browser-speci assert.match(settingsJs, new RegExp(`${apiName}\\.storage\\.local\\.set\\(\\{ selectionShortcutEnabled: selectionShortcutToggle\\.checked \\}\\)`), `${label}: settings should persist toggle changes`); const background = fs.readFileSync(path.join(ROOT, prefix, 'src/background.js'), 'utf8'); + const panelSource = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/sidepanel.js'), 'utf8'); + const agentSource = fs.readFileSync(path.join(ROOT, prefix, 'src/agent/agent.js'), 'utf8'); assert.match(background, /title: 'Ask WebBrain about this'[\s\S]*?parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Open (side panel|sidebar) to chat'/, `${label}: native Ask item should become an action submenu`); assert.match(background, /parentId: CONTEXT_MENU_ASK_SELECTION_ID, title: 'Translate to'/, `${label}: native submenu should include Translate to`); assert.match(background, /Object\.entries\(SELECTION_TRANSLATION_LANGUAGES\)/, `${label}: native Translate submenu should list every supported language`); assert.match(background, /buildSelectionPrompt\(info\.selectionText, 'translate', '', menuItemId\.slice\(CONTEXT_MENU_TRANSLATE_PREFIX\.length\)\)/, `${label}: native language choices should use the safe selection prompt builder`); assert.match(background, /sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING/, `${label}: selected-text payloads should carry structural source grounding`); - assert.match(background, /msg\.sourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING[\s\S]*?\{ sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING \}/, `${label}: only allowlisted grounding should reach agent run options`); + assert.match(background, /msg\.sourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING\s*\?\s*\{\s*sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING,/, `${label}: only allowlisted grounding should reach agent run options`); + assert.match(background, /parentId: CONTEXT_MENU_ASK_SELECTION_ID[\s\S]*?\['humanize', 'Humanize'\]/, `${label}: native submenu should include Humanize`); + assert.match(background, /selectionAction = normalizeSelectionAction\(menuItemId\.slice\(CONTEXT_MENU_ACTION_PREFIX\.length\)\)/, `${label}: native action ids should be normalized before travelling with the prompt`); + assert.match(background, /normalizeSelectionAction\(msg\.selectionAction\)\s*\?\s*\{ selectionAction: normalizeSelectionAction\(msg\.selectionAction\) \}/, `${label}: only a normalized shortcut action should reach agent run options`); + assert.match(content, /data-action="humanize">Humanize<\/button>/, `${label}: floating popup should expose one-click Humanize`); + + // The action travels one hop at a time and every hop re-validates it, so a + // page-authored message cannot name a skill route of its own. + const prompts = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/context-menu-prompts.js'), 'utf8'); + assert.match(prompts, /const selectionAction = sourceGrounding \? normalizeSelectionAction\(payload\?\.selectionAction\) : '';/, `${label}: only a source-bound prompt should keep a shortcut action`); + assert.match(prompts, /\.\.\.\(payload\.selectionAction \? \{ selectionAction: payload\.selectionAction \} : \{\}\),/, `${label}: the stored action should ride with the prompt it belongs to`); + assert.match(panelSource, /const selectionAction = sourceGrounding \? normalizeSelectionAction\(chatExtraParams\.selectionAction\) : '';[\s\S]*?delete chatExtraParams\.selectionAction;/, `${label}: sidepanel should drop an action that arrives without selected-text grounding`); + assert.match(agentSource, /action: normalizeSelectionAction\(runOptions\?\.selectionAction\),/, `${label}: the durable scope should record the shortcut action`); + assert.match(agentSource, /action: normalizeSelectionAction\(entry\.selectionGroundingScope\.action\),/, `${label}: a restarted worker should restore the shortcut action`); + assert.match(agentSource, /selectionAction: normalizeSelectionAction\(scope\?\.action\),/, `${label}: follow-up turns should read the action off the scope, not a resent field`); } const chromeBg = fs.readFileSync(path.join(ROOT, 'src/chrome/src/background.js'), 'utf8'); From c029f25d922598e75d510a98aef4cb4b05714cb7 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Mon, 3 Aug 2026 22:09:21 +0300 Subject: [PATCH 2/3] fix: preserve Humanizer selection routing on retry --- docs/architecture.md | 11 ++++---- docs/fr/skills.md | 16 +++++------ docs/skills.md | 14 +++++----- docs/zh-CN/skills.md | 8 +++--- src/chrome/src/agent/agent.js | 10 +++---- src/chrome/src/context-menu-storage.js | 8 +++--- src/chrome/src/ui/sidepanel.js | 37 ++++++++++++++++++------- src/firefox/src/agent/agent.js | 10 +++---- src/firefox/src/context-menu-storage.js | 8 +++--- src/firefox/src/ui/sidepanel.js | 37 ++++++++++++++++++------- test/run.js | 36 ++++++++++++++++++++---- 11 files changed, 128 insertions(+), 67 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 053d4370f..93079d9dd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -314,10 +314,11 @@ new default IDs can still be migrated into existing installations. read-only article fallback is ready after a structured blocking `pageGate`. Webmail adapter runs preactivate the prompt-only Humanizer skill so a composed reply is rewritten without spending a model-visible `load_skill` hop. - Selected-text runs route on the shortcut action instead (`humanize`, or the - user's own typed request), which the durable selection scope carries across - follow-up turns: they suppress page context, so the adapter is unreliable - there, and their empty tool list leaves no `load_skill` to fall back on. + Selected-text runs route on the explicit `humanize` shortcut action instead, + which the durable selection scope carries across follow-up turns: they + suppress page context, so the adapter is unreliable there, and their empty + tool list leaves no `load_skill` to fall back on. The free-form selection + question box does not imply a writing request and does not activate it. - Tool exposure: `buildSkillToolDefinitions()` reads manifests only from active skills' Markdown bodies (never from Agent Skills frontmatter) and appends compatible schemas to `getToolsForMode(...)` at LLM-call time, respecting @@ -384,7 +385,7 @@ tracks as a successful video or hand ffmpeg work to the user. | Find, read, copy, or enter a code visible in browser email/message content | OTP / verification-code helper | Ask, Act, Dev | Prompt-only; after loading it guides existing page tools. | | Create and use a temporary mailbox for an unimportant signup | Disposable email (Mail.tm) | Act, Dev | Not shown to Ask. It may overlap with OTP during a verification flow, so both can be loaded. | | Read a YouTube transcript, fetch a blocked NYTimes article, or resolve/download supported public media | FreeSkillz.xyz | Ask, Act, Dev | Ask can load the skill but still cannot see its Act-only `download_public_media` tool. | -| Draft or rewrite an email reply, message, or post the user will send | Humanizer | Ask, Act, Dev | Prompt-only; preactivated on webmail adapters and on the Humanize/typed selected-text shortcuts, otherwise routed by catalog. Returns final text only. | +| Draft or rewrite an email reply, message, or post the user will send | Humanizer | Ask, Act, Dev | Prompt-only; preactivated on webmail adapters and on the explicit Humanize selected-text shortcut, otherwise routed by catalog. Returns final text only. | | Look up weather or a short forecast | Open-Meteo weather | Ask, Act, Dev | Read-only tools remain subject to their manifest filters. | | Find books, ISBNs, authors, or publication data | Open Library | Ask, Act, Dev | Read-only tools remain subject to their manifest filters. | | Search or summarize an encyclopedia topic | Wikipedia | Ask, Act, Dev | Read-only Wikipedia REST/Action API tools; results are untrusted. | diff --git a/docs/fr/skills.md b/docs/fr/skills.md index 9afb9a10e..ce03e1310 100644 --- a/docs/fr/skills.md +++ b/docs/fr/skills.md @@ -122,14 +122,14 @@ d'un texte. Elle ne renvoie que le texte final et n'explique pas ses modifications, sauf si vous le demandez. Sélectionnez du texte n'importe où et une entrée **Humanize** apparaît, dans la -fenêtre flottante comme dans le menu contextuel. Elle préactive la compétence -sur tous les sites, tout comme une demande que vous saisissez vous-même dans la -zone de sélection ; les tours suivants de la même conversation la conservent. -Les actions prédéfinies — Summarize, Explain, Quiz me, Proofread, Translate — ne -le font pas, car elles ne rédigent rien que vous alliez envoyer. Ce routage -existe parce qu'une exécution sur texte sélectionné n'embarque aucun outil : le -catalogue lui est inaccessible, et une compétence absente au démarrage ne peut -plus être chargée ensuite. +fenêtre flottante comme dans le menu contextuel. Cette entrée explicite +préactive la compétence sur tous les sites ; les tours suivants de la même +conversation la conservent. Les actions prédéfinies — Summarize, Explain, Quiz +me, Proofread, Translate — et les questions libres saisies dans la zone de +sélection ne le font pas, car elles n'établissent pas une demande de rédaction +structurée. Ce routage existe parce qu'une exécution sur texte sélectionné +n'embarque aucun outil : le catalogue lui est inaccessible, et une compétence +absente au démarrage ne peut plus être chargée ensuite. Elle ne réécrit que la prose destinée à un lecteur humain. Le contenu cité, les adresses, les codes, les prix, les valeurs de champs de formulaire et la diff --git a/docs/skills.md b/docs/skills.md index 7cbb6f285..4963e13bc 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -105,13 +105,13 @@ catalog when the request is about drafting or rewriting text. It returns only the final text; it does not report what it changed unless you ask. Select text anywhere and you get a **Humanize** entry, both in the popup and in -the right-click menu. That entry preactivates the skill on any site, and so does -a request you type yourself into the selection box; later turns in the same -selected-text conversation keep it. The canned readers — Summarize, Explain, -Quiz me, Proofread, Translate — do not, since they compose nothing you are about -to send. This routing exists because a selected-text run carries no tools at -all: the catalog is out of reach, so a skill that is not loaded when the run -starts cannot be loaded later. +the right-click menu. That explicit entry preactivates the skill on any site; +later turns in the same selected-text conversation keep it. The canned readers +— Summarize, Explain, Quiz me, Proofread, Translate — and free-form questions +typed into the selection box do not, since they do not establish a structured +writing request. This routing exists because a selected-text run carries no +tools at all: the catalog is out of reach, so a skill that is not loaded when +the run starts cannot be loaded later. It rewrites only prose being composed for a human reader. Quoted material, addresses, codes, prices, form-field values, and wording you supplied verbatim diff --git a/docs/zh-CN/skills.md b/docs/zh-CN/skills.md index 66d280d03..7b2d8d0d8 100644 --- a/docs/zh-CN/skills.md +++ b/docs/zh-CN/skills.md @@ -90,10 +90,10 @@ Markdown 正文前会移除 frontmatter。在设置中输入的名称及 `webbra 不会说明改动了什么。 在任意页面选中文本后,浮窗和右键菜单里都会出现 **Humanize** 项。它会在任意站点预激活 -该技能;你自己在选区输入框里写下的请求同样如此,并且同一段选中文本会话的后续轮次也会 -保留它。预设的阅读类操作——Summarize、Explain、Quiz me、Proofread、Translate——不会 -预激活,因为它们并不撰写你要发出去的内容。这样路由是因为选中文本的运行完全不携带工具: -技能目录对它不可见,运行开始时没有加载的技能之后也无法再加载。 +该技能,并且同一段选中文本会话的后续轮次也会保留它。预设的阅读类操作——Summarize、 +Explain、Quiz me、Proofread、Translate——以及在选区输入框中输入的自由问题不会预激活, +因为它们没有以结构化方式表明写作请求。这样路由是因为选中文本的运行完全不携带工具:技能 +目录对它不可见,运行开始时没有加载的技能之后也无法再加载。 它只改写面向真人读者的正文。引用内容、地址、验证码、价格、表单字段值,以及你逐字提供 的措辞都保持不变。 diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index ba3f5497b..57062d384 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -10116,11 +10116,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d * An ordinary run matches the webmail adapter and can still reach the skill * through load_skill if the match misses. A selected-text run cannot: it * carries no tools at all, and it suppresses page context, which leaves - * lastSeenAdapter stale or empty. So it routes on the shortcut action the - * user picked — Humanize, or their own typed request — which the durable - * selection scope keeps across follow-up turns. The canned readers - * (summarize, explain, quiz, translate, proofread) produce no prose to send - * and would only pay for the skill body in tokens. + * lastSeenAdapter stale or empty. So it routes only on the explicit Humanize + * shortcut action, which the durable selection scope keeps across follow-up + * turns. The canned readers and general-purpose custom question box do not + * establish a writing request and would only pay for the skill body in + * tokens. */ _preactivateHumanizerSkillForRun(tabId, mode, { selectionOnly = false, selectionAction = '' } = {}) { if (selectionOnly diff --git a/src/chrome/src/context-menu-storage.js b/src/chrome/src/context-menu-storage.js index a836c1452..0e8264fe9 100644 --- a/src/chrome/src/context-menu-storage.js +++ b/src/chrome/src/context-menu-storage.js @@ -12,10 +12,10 @@ export const SELECTION_SHORTCUT_ACTIONS = Object.freeze({ }); // Selected-text runs carry no tools, so `load_skill` cannot rescue a writing -// request mid-run: a prose skill either rides in at run start or never. These -// are the shortcut actions whose output is prose the user will send, kept as -// structured ids so no downstream code has to pattern-match prompt wording. -export const SELECTION_PROSE_ACTIONS = Object.freeze(['humanize', 'custom']); +// request mid-run: a prose skill either rides in at run start or never. Keep +// this limited to explicit structured writing actions; `custom` is the +// general-purpose question box and does not imply a rewrite request. +export const SELECTION_PROSE_ACTIONS = Object.freeze(['humanize']); export function normalizeSelectionAction(value) { const action = String(value == null ? '' : value).trim(); diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 3e3d00adf..fa8ae6772 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -5397,14 +5397,19 @@ function retryPayloadFromButton(btn) { const retryId = btn.dataset.retryId || ''; const attachments = retryAttachmentPayloads.get(retryId) || []; const attachmentCount = Number(btn.dataset.retryAttachmentCount || 0) || 0; + const sourceGrounding = btn.dataset.retrySourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING + ? SELECTION_ONLY_SOURCE_GROUNDING + : null; + const selectionAction = sourceGrounding + ? normalizeSelectionAction(btn.dataset.retrySelectionAction) + : ''; return { text, mode, apiMutationsAllowed: btn.dataset.retryApiMutationsAllowed === 'true', foreground: btn.dataset.retryForeground === 'true', - ...(btn.dataset.retrySourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING - ? { sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING } - : {}), + ...(sourceGrounding ? { sourceGrounding } : {}), + ...(selectionAction ? { selectionAction } : {}), attachments, missingAttachments: attachmentCount > 0 && attachments.length === 0, }; @@ -5439,6 +5444,7 @@ function bindErrorRetryButton(btn) { apiMutationsAllowed: payload.apiMutationsAllowed, foreground: payload.foreground, ...(payload.sourceGrounding ? { sourceGrounding: payload.sourceGrounding } : {}), + ...(payload.selectionAction ? { selectionAction: payload.selectionAction } : {}), attachments: payload.attachments, }, }); @@ -5470,6 +5476,12 @@ function retryPayloadForRunAssistant(assistantEl) { while (userEl && !userEl.matches('.message.user')) userEl = userEl.previousElementSibling; const text = userEl ? getComposerHistoryTextFromMessage(userEl) : ''; if (!String(text || '').trim()) return null; + const sourceGrounding = assistantEl?.dataset.retrySourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING + ? SELECTION_ONLY_SOURCE_GROUNDING + : null; + const selectionAction = sourceGrounding + ? normalizeSelectionAction(assistantEl?.dataset.retrySelectionAction) + : ''; return { text, mode: ['ask', 'act', 'dev'].includes(assistantEl?.dataset.runMode) @@ -5477,9 +5489,8 @@ function retryPayloadForRunAssistant(assistantEl) { : agentMode, apiMutationsAllowed: assistantEl?.dataset.retryApiMutationsAllowed === 'true', foreground: assistantEl?.dataset.retryForeground === 'true', - ...(assistantEl?.dataset.retrySourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING - ? { sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING } - : {}), + ...(sourceGrounding ? { sourceGrounding } : {}), + ...(selectionAction ? { selectionAction } : {}), attachments: [], attachmentCount: Number(assistantEl?.dataset.retryAttachmentCount || 0) || 0, }; @@ -7205,10 +7216,11 @@ async function sendMessage(extraChatParams = {}) { : null; delete chatExtraParams.sourceGrounding; if (sourceGrounding) chatExtraParams.sourceGrounding = sourceGrounding; - // The shortcut action rides along only on the turn that started the scope. - // Later turns have none: the agent reads the action off the durable - // selected-text scope instead of trusting a resent field. - const selectionAction = sourceGrounding ? normalizeSelectionAction(chatExtraParams.selectionAction) : ''; + // The shortcut action rides along only on the turn that started the scope, + // or on an explicit retry of that same turn. Later follow-ups have none: the + // agent reads the action off the durable selected-text scope instead. + const requestedSelectionAction = retryOptions?.selectionAction ?? chatExtraParams.selectionAction; + const selectionAction = sourceGrounding ? normalizeSelectionAction(requestedSelectionAction) : ''; delete chatExtraParams.selectionAction; if (selectionAction) chatExtraParams.selectionAction = selectionAction; await waitForVisibleSidePanelStateRefresh(); @@ -7424,6 +7436,7 @@ async function sendMessage(extraChatParams = {}) { apiMutationsAllowed: apiMutationsAllowedForSend, foreground: foregroundForSend, ...(sourceGrounding ? { sourceGrounding } : {}), + ...(selectionAction ? { selectionAction } : {}), attachments: attachmentsForSend, }; if (renderToCurrentTab) { @@ -7444,6 +7457,7 @@ async function sendMessage(extraChatParams = {}) { assistantEl.dataset.retryApiMutationsAllowed = apiMutationsAllowedForSend ? 'true' : 'false'; assistantEl.dataset.retryForeground = foregroundForSend ? 'true' : 'false'; assistantEl.dataset.retrySourceGrounding = sourceGrounding || ''; + assistantEl.dataset.retrySelectionAction = selectionAction; assistantEl.dataset.retryAttachmentCount = String(attachmentsForSend.length); assistantEl.dataset.lastRenderedSeq = '0'; currentAssistantEl = assistantEl; @@ -9327,6 +9341,9 @@ function configureRetryButton(btn, retryPayload) { btn.dataset.retrySourceGrounding = retryPayload.sourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING ? SELECTION_ONLY_SOURCE_GROUNDING : ''; + btn.dataset.retrySelectionAction = btn.dataset.retrySourceGrounding + ? normalizeSelectionAction(retryPayload.selectionAction) + : ''; const attachmentCount = Number.isFinite(Number(retryPayload.attachmentCount)) ? Math.max(0, Number(retryPayload.attachmentCount)) : attachments.length; diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 510ce77e3..ca8e3e348 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -8878,11 +8878,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d * An ordinary run matches the webmail adapter and can still reach the skill * through load_skill if the match misses. A selected-text run cannot: it * carries no tools at all, and it suppresses page context, which leaves - * lastSeenAdapter stale or empty. So it routes on the shortcut action the - * user picked — Humanize, or their own typed request — which the durable - * selection scope keeps across follow-up turns. The canned readers - * (summarize, explain, quiz, translate, proofread) produce no prose to send - * and would only pay for the skill body in tokens. + * lastSeenAdapter stale or empty. So it routes only on the explicit Humanize + * shortcut action, which the durable selection scope keeps across follow-up + * turns. The canned readers and general-purpose custom question box do not + * establish a writing request and would only pay for the skill body in + * tokens. */ _preactivateHumanizerSkillForRun(tabId, mode, { selectionOnly = false, selectionAction = '' } = {}) { if (selectionOnly diff --git a/src/firefox/src/context-menu-storage.js b/src/firefox/src/context-menu-storage.js index fe071e60d..8c71cb78a 100644 --- a/src/firefox/src/context-menu-storage.js +++ b/src/firefox/src/context-menu-storage.js @@ -12,10 +12,10 @@ export const SELECTION_SHORTCUT_ACTIONS = Object.freeze({ }); // Selected-text runs carry no tools, so `load_skill` cannot rescue a writing -// request mid-run: a prose skill either rides in at run start or never. These -// are the shortcut actions whose output is prose the user will send, kept as -// structured ids so no downstream code has to pattern-match prompt wording. -export const SELECTION_PROSE_ACTIONS = Object.freeze(['humanize', 'custom']); +// request mid-run: a prose skill either rides in at run start or never. Keep +// this limited to explicit structured writing actions; `custom` is the +// general-purpose question box and does not imply a rewrite request. +export const SELECTION_PROSE_ACTIONS = Object.freeze(['humanize']); export function normalizeSelectionAction(value) { const action = String(value == null ? '' : value).trim(); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index a9b58cb0f..368598784 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -5239,14 +5239,19 @@ function retryPayloadFromButton(btn) { const retryId = btn.dataset.retryId || ''; const attachments = retryAttachmentPayloads.get(retryId) || []; const attachmentCount = Number(btn.dataset.retryAttachmentCount || 0) || 0; + const sourceGrounding = btn.dataset.retrySourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING + ? SELECTION_ONLY_SOURCE_GROUNDING + : null; + const selectionAction = sourceGrounding + ? normalizeSelectionAction(btn.dataset.retrySelectionAction) + : ''; return { text, mode, apiMutationsAllowed: btn.dataset.retryApiMutationsAllowed === 'true', foreground: btn.dataset.retryForeground === 'true', - ...(btn.dataset.retrySourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING - ? { sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING } - : {}), + ...(sourceGrounding ? { sourceGrounding } : {}), + ...(selectionAction ? { selectionAction } : {}), attachments, missingAttachments: attachmentCount > 0 && attachments.length === 0, }; @@ -5281,6 +5286,7 @@ function bindErrorRetryButton(btn) { apiMutationsAllowed: payload.apiMutationsAllowed, foreground: payload.foreground, ...(payload.sourceGrounding ? { sourceGrounding: payload.sourceGrounding } : {}), + ...(payload.selectionAction ? { selectionAction: payload.selectionAction } : {}), attachments: payload.attachments, }, }); @@ -5312,6 +5318,12 @@ function retryPayloadForRunAssistant(assistantEl) { while (userEl && !userEl.matches('.message.user')) userEl = userEl.previousElementSibling; const text = userEl ? getComposerHistoryTextFromMessage(userEl) : ''; if (!String(text || '').trim()) return null; + const sourceGrounding = assistantEl?.dataset.retrySourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING + ? SELECTION_ONLY_SOURCE_GROUNDING + : null; + const selectionAction = sourceGrounding + ? normalizeSelectionAction(assistantEl?.dataset.retrySelectionAction) + : ''; return { text, mode: ['ask', 'act', 'dev'].includes(assistantEl?.dataset.runMode) @@ -5319,9 +5331,8 @@ function retryPayloadForRunAssistant(assistantEl) { : agentMode, apiMutationsAllowed: assistantEl?.dataset.retryApiMutationsAllowed === 'true', foreground: assistantEl?.dataset.retryForeground === 'true', - ...(assistantEl?.dataset.retrySourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING - ? { sourceGrounding: SELECTION_ONLY_SOURCE_GROUNDING } - : {}), + ...(sourceGrounding ? { sourceGrounding } : {}), + ...(selectionAction ? { selectionAction } : {}), attachments: [], attachmentCount: Number(assistantEl?.dataset.retryAttachmentCount || 0) || 0, }; @@ -6947,10 +6958,11 @@ async function sendMessage(extraChatParams = {}) { : null; delete chatExtraParams.sourceGrounding; if (sourceGrounding) chatExtraParams.sourceGrounding = sourceGrounding; - // The shortcut action rides along only on the turn that started the scope. - // Later turns have none: the agent reads the action off the durable - // selected-text scope instead of trusting a resent field. - const selectionAction = sourceGrounding ? normalizeSelectionAction(chatExtraParams.selectionAction) : ''; + // The shortcut action rides along only on the turn that started the scope, + // or on an explicit retry of that same turn. Later follow-ups have none: the + // agent reads the action off the durable selected-text scope instead. + const requestedSelectionAction = retryOptions?.selectionAction ?? chatExtraParams.selectionAction; + const selectionAction = sourceGrounding ? normalizeSelectionAction(requestedSelectionAction) : ''; delete chatExtraParams.selectionAction; if (selectionAction) chatExtraParams.selectionAction = selectionAction; await waitForVisibleSidePanelStateRefresh(); @@ -7161,6 +7173,7 @@ async function sendMessage(extraChatParams = {}) { apiMutationsAllowed: apiMutationsAllowedForSend, foreground: foregroundForSend, ...(sourceGrounding ? { sourceGrounding } : {}), + ...(selectionAction ? { selectionAction } : {}), attachments: attachmentsForSend, }; if (renderToCurrentTab) { @@ -7181,6 +7194,7 @@ async function sendMessage(extraChatParams = {}) { assistantEl.dataset.retryApiMutationsAllowed = apiMutationsAllowedForSend ? 'true' : 'false'; assistantEl.dataset.retryForeground = foregroundForSend ? 'true' : 'false'; assistantEl.dataset.retrySourceGrounding = sourceGrounding || ''; + assistantEl.dataset.retrySelectionAction = selectionAction; assistantEl.dataset.retryAttachmentCount = String(attachmentsForSend.length); assistantEl.dataset.lastRenderedSeq = '0'; currentAssistantEl = assistantEl; @@ -8980,6 +8994,9 @@ function configureRetryButton(btn, retryPayload) { btn.dataset.retrySourceGrounding = retryPayload.sourceGrounding === SELECTION_ONLY_SOURCE_GROUNDING ? SELECTION_ONLY_SOURCE_GROUNDING : ''; + btn.dataset.retrySelectionAction = btn.dataset.retrySourceGrounding + ? normalizeSelectionAction(retryPayload.selectionAction) + : ''; const attachmentCount = Number.isFinite(Number(retryPayload.attachmentCount)) ? Math.max(0, Number(retryPayload.attachmentCount)) : attachments.length; diff --git a/test/run.js b/test/run.js index 5a2d8481d..01bf07103 100644 --- a/test/run.js +++ b/test/run.js @@ -13246,7 +13246,7 @@ test('mail and selected-text runs preactivate the Humanizer skill without wideni // Selected-text runs suppress page context, so the adapter is empty or // stale; they route on the shortcut action instead, and their empty tool // list means load_skill can never load the skill afterwards. - for (const action of ['humanize', 'custom']) { + for (const action of ['humanize']) { assert.equal( agent._preactivateHumanizerSkillForRun(tabId, 'ask', { selectionOnly: true, selectionAction: action }), true, @@ -13256,9 +13256,10 @@ test('mail and selected-text runs preactivate the Humanizer skill without wideni agent._resetActiveSkillsForRun(tabId, { refreshPrompt: false }); } - // The canned readers compose nothing the user sends, so they must not pay - // for the skill body — and neither must an unknown or absent action. - for (const action of ['summarize', 'explain', 'quiz', 'translate', 'proofread', 'load_skill', '']) { + // The free-form question box and canned readers do not establish a writing + // request, so they must not pay for the skill body. Neither may an unknown + // or absent action. + for (const action of ['custom', 'summarize', 'explain', 'quiz', 'translate', 'proofread', 'load_skill', '']) { assert.equal( agent._preactivateHumanizerSkillForRun(tabId, 'ask', { selectionOnly: true, selectionAction: action }), false, @@ -23257,6 +23258,31 @@ test('sidepanel preserves selection-only grounding across retries and attachment /dataset\.retrySourceGrounding[\s\S]*?SELECTION_ONLY_SOURCE_GROUNDING/, `${label}: rendered retry controls should preserve the selection boundary`, ); + assert.match( + panel, + /const requestedSelectionAction = retryOptions\?\.selectionAction \?\? chatExtraParams\.selectionAction;[\s\S]*?normalizeSelectionAction\(requestedSelectionAction\)/, + `${label}: retries should restore their normalized selection action`, + ); + assert.match( + panel, + /const retryPayload = \{[\s\S]*?\.\.\.\(selectionAction \? \{ selectionAction \} : \{\}\),[\s\S]*?assistantEl\.dataset\.retrySelectionAction = selectionAction;/, + `${label}: live and persisted retry state should retain the selection action`, + ); + assert.match( + panel, + /function configureRetryButton\([\s\S]*?btn\.dataset\.retrySelectionAction = btn\.dataset\.retrySourceGrounding[\s\S]*?normalizeSelectionAction\(retryPayload\.selectionAction\)/, + `${label}: retry buttons should retain only a grounded normalized selection action`, + ); + assert.match( + panel, + /function retryPayloadFromButton\([\s\S]*?normalizeSelectionAction\(btn\.dataset\.retrySelectionAction\)[\s\S]*?\.\.\.\(selectionAction \? \{ selectionAction \} : \{\}\),/, + `${label}: retry button reads should restore the selection action`, + ); + assert.match( + panel, + /function retryPayloadForRunAssistant\([\s\S]*?normalizeSelectionAction\(assistantEl\?\.dataset\.retrySelectionAction\)[\s\S]*?\.\.\.\(selectionAction \? \{ selectionAction \} : \{\}\),/, + `${label}: reconstructed retries should restore the persisted selection action`, + ); const agent = fs.readFileSync(path.join(ROOT, prefix, 'src/agent/agent.js'), 'utf8'); assert.match( @@ -23432,7 +23458,7 @@ test('selection shortcut is shipped, enabled by default, and keeps browser-speci const prompts = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/context-menu-prompts.js'), 'utf8'); assert.match(prompts, /const selectionAction = sourceGrounding \? normalizeSelectionAction\(payload\?\.selectionAction\) : '';/, `${label}: only a source-bound prompt should keep a shortcut action`); assert.match(prompts, /\.\.\.\(payload\.selectionAction \? \{ selectionAction: payload\.selectionAction \} : \{\}\),/, `${label}: the stored action should ride with the prompt it belongs to`); - assert.match(panelSource, /const selectionAction = sourceGrounding \? normalizeSelectionAction\(chatExtraParams\.selectionAction\) : '';[\s\S]*?delete chatExtraParams\.selectionAction;/, `${label}: sidepanel should drop an action that arrives without selected-text grounding`); + assert.match(panelSource, /const requestedSelectionAction = retryOptions\?\.selectionAction \?\? chatExtraParams\.selectionAction;[\s\S]*?const selectionAction = sourceGrounding \? normalizeSelectionAction\(requestedSelectionAction\) : '';[\s\S]*?delete chatExtraParams\.selectionAction;/, `${label}: sidepanel should retain retry actions but drop actions without selected-text grounding`); assert.match(agentSource, /action: normalizeSelectionAction\(runOptions\?\.selectionAction\),/, `${label}: the durable scope should record the shortcut action`); assert.match(agentSource, /action: normalizeSelectionAction\(entry\.selectionGroundingScope\.action\),/, `${label}: a restarted worker should restore the shortcut action`); assert.match(agentSource, /selectionAction: normalizeSelectionAction\(scope\?\.action\),/, `${label}: follow-up turns should read the action off the scope, not a resent field`); From 6fae34d96162f06580cff94ade21bc83445dc44b Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Mon, 3 Aug 2026 22:16:01 +0300 Subject: [PATCH 3/3] fix: keep selection popup within viewport --- src/chrome/src/content/selection-shortcut.js | 4 +++- src/firefox/src/content/selection-shortcut.js | 4 +++- test/run.js | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/chrome/src/content/selection-shortcut.js b/src/chrome/src/content/selection-shortcut.js index cc72b8bd3..39fe5aead 100644 --- a/src/chrome/src/content/selection-shortcut.js +++ b/src/chrome/src/content/selection-shortcut.js @@ -142,7 +142,9 @@ transform:translateY(-1px); } .popup { - position:fixed; width:min(${POPUP_WIDTH}px,calc(100vw - 16px)); padding:12px; + position:fixed; width:min(${POPUP_WIDTH}px,calc(100vw - 16px)); + max-height:calc(100vh - 16px); overflow-y:auto; overscroll-behavior:contain; + padding:12px; border:1px solid var(--border); border-radius:16px; background:var(--bg); color:var(--text); box-shadow:var(--shadow); pointer-events:auto; font:15px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; diff --git a/src/firefox/src/content/selection-shortcut.js b/src/firefox/src/content/selection-shortcut.js index cc72b8bd3..39fe5aead 100644 --- a/src/firefox/src/content/selection-shortcut.js +++ b/src/firefox/src/content/selection-shortcut.js @@ -142,7 +142,9 @@ transform:translateY(-1px); } .popup { - position:fixed; width:min(${POPUP_WIDTH}px,calc(100vw - 16px)); padding:12px; + position:fixed; width:min(${POPUP_WIDTH}px,calc(100vw - 16px)); + max-height:calc(100vh - 16px); overflow-y:auto; overscroll-behavior:contain; + padding:12px; border:1px solid var(--border); border-radius:16px; background:var(--bg); color:var(--text); box-shadow:var(--shadow); pointer-events:auto; font:15px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; diff --git a/test/run.js b/test/run.js index 01bf07103..38c22cf34 100644 --- a/test/run.js +++ b/test/run.js @@ -23421,6 +23421,7 @@ test('selection shortcut is shipped, enabled by default, and keeps browser-speci assert.match(content, /button\.dataset\.action === 'translate'\) submitSelection\('translate', '', interfaceLanguage\)/, `${label}: floating Translate should submit directly in the plugin language`); assert.match(content, /class="shortcut-icon" aria-hidden="true">\?<\/span>/, `${label}: shortcut should use the compact question-mark icon`); assert.match(content, /border:1px solid rgba\(108,99,255,\.34\);[\s\S]*?color:var\(--accent\);/, `${label}: shortcut should use the WebBrain purple treatment`); + assert.match(content, /\.popup \{[\s\S]*?max-height:calc\(100vh - 16px\); overflow-y:auto; overscroll-behavior:contain;/, `${label}: expanded popup should remain scrollable inside short viewports`); assert.doesNotMatch(content, /M6\.8 8\.5 9\.2 14l2\.8-3\.4 2\.8 3\.4 2\.4-5\.5/, `${label}: discarded WebBrain W outline should be removed`); assert.doesNotMatch(content, /M12 2\.8c\.65 3\.78/, `${label}: Claude-like sparkle icon should be removed`); assert.match(content, /const MAX_SELECTION_HIGHLIGHT_RECTS = 200;/, `${label}: selection highlights should have a hard DOM-node limit`);