From ad43a2c8514712d23c7caf3ef8bce84eb4a05785 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Mon, 20 Apr 2026 09:44:52 +0800 Subject: [PATCH 1/9] fix: call sidePanel.open() synchronously to fix context menu side panel (fixes #857) Chrome's sidePanel.open() must be called synchronously within a user gesture handler. Previously, the call was inside a Browser.tabs.query() Promise callback, which breaks the user gesture chain and causes: Error: sidePanel.open() may only be called in response to a user gesture. Fixed by extracting the itemId before the async query and handling the openSidePanel action synchronously at the top of the event handler, before any async operations. --- src/background/menus.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/background/menus.mjs b/src/background/menus.mjs index a92bd7ba1..d742ea110 100644 --- a/src/background/menus.mjs +++ b/src/background/menus.mjs @@ -5,10 +5,21 @@ import { config as menuConfig } from '../content-script/menu-tools/index.mjs' const menuId = 'ChatGPTBox-Menu' const onClickMenu = (info, tab) => { + const itemId = info.menuItemId.replace(menuId, '') + + // sidePanel.open() must be called synchronously within the user gesture handler. + // Calling it inside a Promise callback (e.g. Browser.tabs.query().then()) breaks + // Chrome's user gesture requirement and causes the error: + // "sidePanel.open() may only be called in response to a user gesture." + if (itemId === 'openSidePanel' && menuConfig.openSidePanel?.action) { + menuConfig.openSidePanel.action(true, tab) + return + } + Browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { const currentTab = tabs[0] const message = { - itemId: info.menuItemId.replace(menuId, ''), + itemId, selectionText: info.selectionText, useMenuPosition: tab.id === currentTab.id, } From 4a2cb688f3591ce0cbf9e0685cd7066ac1ba6b9f Mon Sep 17 00:00:00 2001 From: octo-patch Date: Mon, 20 Apr 2026 13:11:29 +0800 Subject: [PATCH 2/9] fix(menus): observe sidePanel.open promise to avoid unhandled rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synchronous menuConfig.openSidePanel.action(true, tab) call is intentional — chrome.sidePanel.open() must run inside the user-gesture callback or it throws 'sidePanel.open() may only be called in response to a user gesture'. We don't want to break that. But the action used to be async, so the chrome.sidePanel.open Promise was discarded and any rejection (e.g. an invalid tab/window combo) would surface as an unhandled rejection in the background script. Two small changes: - menu-tools/index.mjs: drop the async wrapper and return the chrome.sidePanel.open() promise directly so the caller can observe it. - background/menus.mjs: still call the action synchronously (gesture preserved) but, if it returned a thenable, attach a .catch that logs the failure instead of letting it become unhandled. Addresses review feedback from CodeRabbit and gemini-code-assist. --- src/background/menus.mjs | 10 +++++++++- src/content-script/menu-tools/index.mjs | 8 ++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/background/menus.mjs b/src/background/menus.mjs index d742ea110..0565836b4 100644 --- a/src/background/menus.mjs +++ b/src/background/menus.mjs @@ -12,7 +12,15 @@ const onClickMenu = (info, tab) => { // Chrome's user gesture requirement and causes the error: // "sidePanel.open() may only be called in response to a user gesture." if (itemId === 'openSidePanel' && menuConfig.openSidePanel?.action) { - menuConfig.openSidePanel.action(true, tab) + // Keep the call synchronous to preserve the user-gesture requirement, + // but observe the returned Promise so a rejected sidePanel.open() does + // not become an unhandled rejection in the background script. + const result = menuConfig.openSidePanel.action(true, tab) + if (result && typeof result.catch === 'function') { + result.catch((error) => { + console.error('failed to open side panel', error) + }) + } return } diff --git a/src/content-script/menu-tools/index.mjs b/src/content-script/menu-tools/index.mjs index 0f46392ec..77fdc6345 100644 --- a/src/content-script/menu-tools/index.mjs +++ b/src/content-script/menu-tools/index.mjs @@ -59,14 +59,14 @@ export const config = { }, openSidePanel: { label: 'Open Side Panel', - action: async (fromBackground, tab) => { + action: (fromBackground, tab) => { console.debug('action is from background', fromBackground) if (fromBackground) { // eslint-disable-next-line no-undef - chrome.sidePanel.open({ windowId: tab.windowId, tabId: tab.id }) - } else { - // side panel is not supported + return chrome.sidePanel.open({ windowId: tab.windowId, tabId: tab.id }) } + // side panel is not supported + return undefined }, }, closeAllChats: { From 132ae28c906067a054252f58c68bce8bfa5a8660 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Mon, 20 Apr 2026 13:21:47 +0800 Subject: [PATCH 3/9] fix(menus): observe sidePanel.open promise in command handler & guard against missing chrome.sidePanel Addresses two follow-up review findings on PR #963: 1. devin-ai-integration[bot]: src/background/commands.mjs:15 had the same unhandled-rejection pattern as menus.mjs. The keyboard command path now mirrors the menus.mjs treatment: call the action synchronously to preserve user-gesture context, then attach a .catch() to the returned thenable so a rejected sidePanel.open() does not bubble up as an unhandled rejection. 2. coderabbitai[bot]: src/content-script/menu-tools/index.mjs:62-69 called chrome.sidePanel.open directly, which throws synchronously in browsers where chrome.sidePanel is not defined (e.g. Firefox). Guard the call with a typeof chrome / chrome.sidePanel check and return a rejected Promise so the caller's .catch() handles it uniformly with API rejections. --- src/background/commands.mjs | 11 ++++++++++- src/content-script/menu-tools/index.mjs | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/background/commands.mjs b/src/background/commands.mjs index 1440d74d4..54996b165 100644 --- a/src/background/commands.mjs +++ b/src/background/commands.mjs @@ -12,7 +12,16 @@ export function registerCommands() { if (command in menuConfig) { if (menuConfig[command].action) { - menuConfig[command].action(true, tab) + // The action may return a Promise (e.g. openSidePanel returns the + // chrome.sidePanel.open() Promise). Keep the call synchronous so the + // user-gesture context is preserved, but observe the Promise so a + // rejection does not become an unhandled rejection in the background. + const result = menuConfig[command].action(true, tab) + if (result && typeof result.catch === 'function') { + result.catch((error) => { + console.error(`failed to run command action "${command}"`, error) + }) + } } if (menuConfig[command].genPrompt) { diff --git a/src/content-script/menu-tools/index.mjs b/src/content-script/menu-tools/index.mjs index 77fdc6345..21e82eea1 100644 --- a/src/content-script/menu-tools/index.mjs +++ b/src/content-script/menu-tools/index.mjs @@ -62,6 +62,11 @@ export const config = { action: (fromBackground, tab) => { console.debug('action is from background', fromBackground) if (fromBackground) { + // eslint-disable-next-line no-undef + if (typeof chrome === 'undefined' || !chrome.sidePanel?.open) { + // sidePanel API is not available in this browser (e.g. Firefox) + return Promise.reject(new Error('chrome.sidePanel API is not available')) + } // eslint-disable-next-line no-undef return chrome.sidePanel.open({ windowId: tab.windowId, tabId: tab.id }) } From 57191a9598defda607594c3b599655eab9ae4dff Mon Sep 17 00:00:00 2001 From: octo-patch Date: Mon, 20 Apr 2026 13:36:26 +0800 Subject: [PATCH 4/9] fix(commands): also catch synchronous throws in command action `Browser.commands.onCommand`'s `tab` parameter is documented as optional, so an action that dereferences `tab.*` (e.g. `openSidePanel` reading `tab.windowId` / `tab.id`) can throw synchronously before returning a Promise. The previous `.catch` only observed Promise rejections, so a synchronous throw would still surface as an uncaught error in the background script. Wrap the action invocation in try/catch and log+return on synchronous failure, mirroring the async rejection handling. Addresses CodeRabbit review on PR #963. --- src/background/commands.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/background/commands.mjs b/src/background/commands.mjs index 54996b165..47577cf6a 100644 --- a/src/background/commands.mjs +++ b/src/background/commands.mjs @@ -16,7 +16,16 @@ export function registerCommands() { // chrome.sidePanel.open() Promise). Keep the call synchronous so the // user-gesture context is preserved, but observe the Promise so a // rejection does not become an unhandled rejection in the background. - const result = menuConfig[command].action(true, tab) + // Also wrap in try/catch because Browser.commands.onCommand documents + // `tab` as optional, so an action that dereferences tab.* (e.g. the + // openSidePanel call) can throw synchronously. + let result + try { + result = menuConfig[command].action(true, tab) + } catch (error) { + console.error(`failed to run command action "${command}"`, error) + return + } if (result && typeof result.catch === 'function') { result.catch((error) => { console.error(`failed to run command action "${command}"`, error) From 17dbbd5732a1704d1080d5ecd44e9ece68cfeeb5 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Mon, 20 Apr 2026 13:42:47 +0800 Subject: [PATCH 5/9] fix: also wrap menus.mjs openSidePanel call in try/catch contextMenus.onClicked.tab is optional per the Chrome API ('If the click did not take place in a tab, this parameter will be missing'). The openSidePanel action dereferences tab.windowId/tab.id, so it can throw synchronously, which the existing .catch() Promise handler would not catch. Mirror the pattern already used in src/background/commands.mjs by wrapping the call in try/catch. Addresses devin-ai-integration[bot] review on src/background/menus.mjs:23. --- src/background/menus.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/background/menus.mjs b/src/background/menus.mjs index 0565836b4..7f0864990 100644 --- a/src/background/menus.mjs +++ b/src/background/menus.mjs @@ -15,7 +15,17 @@ const onClickMenu = (info, tab) => { // Keep the call synchronous to preserve the user-gesture requirement, // but observe the returned Promise so a rejected sidePanel.open() does // not become an unhandled rejection in the background script. - const result = menuConfig.openSidePanel.action(true, tab) + // Also wrap in try/catch because contextMenus.onClicked documents `tab` + // as optional ("If the click did not take place in a tab, this parameter + // will be missing"), so the openSidePanel action that dereferences + // tab.windowId/tab.id can throw synchronously. + let result + try { + result = menuConfig.openSidePanel.action(true, tab) + } catch (error) { + console.error('failed to open side panel', error) + return + } if (result && typeof result.catch === 'function') { result.catch((error) => { console.error('failed to open side panel', error) From 44e9349dd88b6d3650f0788836ab11814ac574c1 Mon Sep 17 00:00:00 2001 From: Octopus Date: Tue, 12 May 2026 13:21:28 +0800 Subject: [PATCH 6/9] fix(menus): guard optional tab and catch tabs.query rejections - guard tab/currentTab before dereferencing in onClickMenu so the documented optional tab parameter from contextMenus.onClicked does not cause a TypeError when the click happens outside of a tab - handle Browser.tabs.query() rejections so a failed query no longer becomes an unhandled promise rejection in the background script --- src/background/menus.mjs | 53 ++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/src/background/menus.mjs b/src/background/menus.mjs index 7f0864990..a3c17c7b5 100644 --- a/src/background/menus.mjs +++ b/src/background/menus.mjs @@ -34,33 +34,48 @@ const onClickMenu = (info, tab) => { return } - Browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { - const currentTab = tabs[0] - const message = { - itemId, - selectionText: info.selectionText, - useMenuPosition: tab.id === currentTab.id, - } - console.debug('menu clicked', message) + Browser.tabs + .query({ active: true, currentWindow: true }) + .then((tabs) => { + const currentTab = tabs && tabs[0] + if (!currentTab) { + console.debug('menu clicked but no active tab found, skipping') + return + } - if (defaultConfig.selectionTools.includes(message.itemId)) { - Browser.tabs.sendMessage(currentTab.id, { - type: 'CREATE_CHAT', - data: message, - }) - } else if (message.itemId in menuConfig) { - if (menuConfig[message.itemId].action) { - menuConfig[message.itemId].action(true, tab) + // contextMenus.onClicked documents `tab` as optional ("If the click did + // not take place in a tab, this parameter will be missing"), so guard + // before dereferencing tab.id when computing useMenuPosition. + const message = { + itemId, + selectionText: info.selectionText, + useMenuPosition: tab ? tab.id === currentTab.id : false, } + console.debug('menu clicked', message) - if (menuConfig[message.itemId].genPrompt) { + if (defaultConfig.selectionTools.includes(message.itemId)) { Browser.tabs.sendMessage(currentTab.id, { type: 'CREATE_CHAT', data: message, }) + } else if (message.itemId in menuConfig) { + if (menuConfig[message.itemId].action) { + menuConfig[message.itemId].action(true, tab) + } + + if (menuConfig[message.itemId].genPrompt) { + Browser.tabs.sendMessage(currentTab.id, { + type: 'CREATE_CHAT', + data: message, + }) + } } - } - }) + }) + .catch((error) => { + // Browser.tabs.query() can reject (e.g. on permission errors); make sure + // it does not become an unhandled promise rejection in the background. + console.error('failed to query active tab for menu click', error) + }) } export function refreshMenu() { if (Browser.contextMenus.onClicked.hasListener(onClickMenu)) From 2a824105f2c37c57dcc5ced8d3cdf6c6eaebb22e Mon Sep 17 00:00:00 2001 From: Octopus Date: Fri, 15 May 2026 13:07:59 +0800 Subject: [PATCH 7/9] fix(menus): guard openSidePanel action and observe non-sidePanel menu actions Two follow-ups from review on PR #963: - src/content-script/menu-tools/index.mjs: guard openSidePanel.action against missing tab / tab.id / tab.windowId and return a rejected Promise so callers do not have to wrap every invocation in try/catch just to avoid a TypeError when the click happens outside a tab. Both contextMenus.onClicked and commands.onCommand document tab as optional. - src/background/menus.mjs: in the non-openSidePanel branch, capture the return value of menuConfig[itemId].action(true, tab) and observe both synchronous throws and Promise rejections, mirroring the handling already used for the openSidePanel special case here and in commands.mjs. Several actions in menuConfig are async (tabs/windows calls) and could otherwise leak unhandled rejections in the background script. Addresses PeterDaveHello review feedback on PR #963. --- src/background/menus.mjs | 17 ++++++++++++++++- src/content-script/menu-tools/index.mjs | 10 ++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/background/menus.mjs b/src/background/menus.mjs index a3c17c7b5..7e02f1765 100644 --- a/src/background/menus.mjs +++ b/src/background/menus.mjs @@ -60,7 +60,22 @@ const onClickMenu = (info, tab) => { }) } else if (message.itemId in menuConfig) { if (menuConfig[message.itemId].action) { - menuConfig[message.itemId].action(true, tab) + // Several actions in menuConfig are async (e.g. tabs/windows calls) + // and can throw synchronously or return a rejected Promise. Mirror + // the handling already used for openSidePanel above and in + // commands.mjs so neither path leaks an unhandled rejection in the + // background script. + let actionResult + try { + actionResult = menuConfig[message.itemId].action(true, tab) + } catch (error) { + console.error(`failed to run menu action "${message.itemId}"`, error) + } + if (actionResult && typeof actionResult.catch === 'function') { + actionResult.catch((error) => { + console.error(`failed to run menu action "${message.itemId}"`, error) + }) + } } if (menuConfig[message.itemId].genPrompt) { diff --git a/src/content-script/menu-tools/index.mjs b/src/content-script/menu-tools/index.mjs index 21e82eea1..f777b5251 100644 --- a/src/content-script/menu-tools/index.mjs +++ b/src/content-script/menu-tools/index.mjs @@ -67,6 +67,16 @@ export const config = { // sidePanel API is not available in this browser (e.g. Firefox) return Promise.reject(new Error('chrome.sidePanel API is not available')) } + // contextMenus.onClicked / commands.onCommand document `tab` as + // optional, and even when present the tab may not have an id or + // windowId (e.g. clicks outside a normal browser tab). Guard here so + // callers do not have to wrap every invocation in try/catch just to + // avoid a TypeError from dereferencing tab.windowId / tab.id. + if (!tab || tab.windowId == null || tab.id == null) { + return Promise.reject( + new Error('chrome.sidePanel.open requires a tab with windowId and id'), + ) + } // eslint-disable-next-line no-undef return chrome.sidePanel.open({ windowId: tab.windowId, tabId: tab.id }) } From c8bfcf340705d3c116c8d155f070189293739233 Mon Sep 17 00:00:00 2001 From: Octopus Date: Sat, 16 May 2026 13:05:09 +0800 Subject: [PATCH 8/9] fix(menus): observe Browser.tabs.sendMessage promises in onClickMenu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Browser.tabs.sendMessage()` (via webextension-polyfill) returns a Promise that commonly rejects in normal extension usage — when no content script is listening yet, on restricted pages (chrome://, about:, etc.), or after an extension reload makes old content scripts stale. Both call sites in onClickMenu were leaving the returned Promise unobserved, so a rejection would surface as an unhandled rejection in the background script. Attach a .catch() to both sendMessage call sites (selectionTools branch and the menuConfig genPrompt branch), mirroring the handling already applied to Browser.tabs.query() and the menu/command actions in this PR. This completes the unhandled-rejection story for the whole onClickMenu handler. Co-Authored-By: Octopus --- src/background/menus.mjs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/background/menus.mjs b/src/background/menus.mjs index 7e02f1765..d3a61ee1e 100644 --- a/src/background/menus.mjs +++ b/src/background/menus.mjs @@ -54,10 +54,18 @@ const onClickMenu = (info, tab) => { console.debug('menu clicked', message) if (defaultConfig.selectionTools.includes(message.itemId)) { - Browser.tabs.sendMessage(currentTab.id, { - type: 'CREATE_CHAT', - data: message, - }) + // Browser.tabs.sendMessage() (via webextension-polyfill) returns a + // Promise that commonly rejects (no content script listening, restricted + // pages such as chrome://, stale content scripts after extension reload) + // — observe it so we don't leak unhandled rejections in the background. + Browser.tabs + .sendMessage(currentTab.id, { + type: 'CREATE_CHAT', + data: message, + }) + .catch((error) => { + console.error(`failed to send CREATE_CHAT message for "${message.itemId}"`, error) + }) } else if (message.itemId in menuConfig) { if (menuConfig[message.itemId].action) { // Several actions in menuConfig are async (e.g. tabs/windows calls) @@ -79,10 +87,17 @@ const onClickMenu = (info, tab) => { } if (menuConfig[message.itemId].genPrompt) { - Browser.tabs.sendMessage(currentTab.id, { - type: 'CREATE_CHAT', - data: message, - }) + // Same rationale as the sendMessage call above — observe the Promise + // so a rejected sendMessage (no content script, restricted page, etc.) + // doesn't surface as an unhandled rejection in the background. + Browser.tabs + .sendMessage(currentTab.id, { + type: 'CREATE_CHAT', + data: message, + }) + .catch((error) => { + console.error(`failed to send CREATE_CHAT message for "${message.itemId}"`, error) + }) } } }) From 57d37ba9033aee552f6733cf1d05b518168bc6a7 Mon Sep 17 00:00:00 2001 From: Octopus Date: Sat, 16 May 2026 13:15:49 +0800 Subject: [PATCH 9/9] fix(commands): observe tabs.query/sendMessage promises in genPrompt path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the pattern already applied in menus.mjs to the keyboard-command genPrompt branch in commands.mjs so no step here can surface as an unhandled promise rejection in the background script: - Browser.tabs.query() is wrapped in try/catch (it can reject on permission errors, etc.). - The resulting tabs[0] is guarded before dereferencing currentTab.id — contextMenus / commands handlers can fire when no active tab is resolvable, matching the guard menus.mjs already added. - Browser.tabs.sendMessage() now has a .catch() that logs and swallows the rejection. webextension-polyfill sendMessage rejects in normal extension usage when no content script is listening, when the page is restricted (chrome://, about:, etc.), or when the content script is stale after an extension reload. Same rationale as commit c8bfcf3 for menus.mjs — addresses the Copilot review on src/background/commands.mjs:33 (PR #963). Lint, prettier, and the test suite (315 tests) all pass. Co-Authored-By: Octopus --- src/background/commands.mjs | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/background/commands.mjs b/src/background/commands.mjs index 47577cf6a..cba9f7dbd 100644 --- a/src/background/commands.mjs +++ b/src/background/commands.mjs @@ -34,11 +34,36 @@ export function registerCommands() { } if (menuConfig[command].genPrompt) { - const currentTab = (await Browser.tabs.query({ active: true, currentWindow: true }))[0] - Browser.tabs.sendMessage(currentTab.id, { - type: 'CREATE_CHAT', - data: message, - }) + // Mirror the pattern in menus.mjs so no step here can leak an + // unhandled rejection in the background: + // - Browser.tabs.query() can reject (permission errors, etc.) — + // observe via try/catch. + // - tabs[0] may be undefined when no active tab exists — guard + // before dereferencing currentTab.id. + // - Browser.tabs.sendMessage() (via webextension-polyfill) rejects + // in normal extension usage (no content script listening, + // restricted pages like chrome://, stale content scripts after + // extension reload) — attach a .catch(). + let tabs + try { + tabs = await Browser.tabs.query({ active: true, currentWindow: true }) + } catch (error) { + console.error(`failed to query active tab for command "${command}"`, error) + return + } + const currentTab = tabs && tabs[0] + if (!currentTab) { + console.debug(`command "${command}" triggered but no active tab found, skipping`) + return + } + Browser.tabs + .sendMessage(currentTab.id, { + type: 'CREATE_CHAT', + data: message, + }) + .catch((error) => { + console.error(`failed to send CREATE_CHAT message for command "${command}"`, error) + }) } } })