From e1fede1194006f76e4bf61bd122ef59f7f8dee25 Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:18:13 +0800 Subject: [PATCH 1/5] feat(ext): option to control all tabs --- .../extension/src/agent/MultiPageAgent.ts | 11 +++-- .../src/agent/TabsController.background.ts | 13 ++++++ .../extension/src/agent/TabsController.ts | 42 +++++++++++++++---- packages/extension/src/agent/useAgent.ts | 1 + .../extension/src/entrypoints/main-world.ts | 4 ++ 5 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/extension/src/agent/MultiPageAgent.ts b/packages/extension/src/agent/MultiPageAgent.ts index f2e7b0a..aa768bc 100644 --- a/packages/extension/src/agent/MultiPageAgent.ts +++ b/packages/extension/src/agent/MultiPageAgent.ts @@ -11,13 +11,18 @@ function detectLanguage(): 'en-US' | 'zh-CN' { return lang.startsWith('zh') ? 'zh-CN' : 'en-US' } +interface MultiPageAgentConfig extends AgentConfig { + includeInitialTab?: boolean + experimentalIncludeAllTabs?: boolean +} + /** * MultiPageAgent * - use with extension * - can be used from a side panel or a content script */ export class MultiPageAgent extends PageAgentCore { - constructor(config: AgentConfig & { includeInitialTab?: boolean }) { + constructor(config: MultiPageAgentConfig) { // multi page controller const tabsController = new TabsController() const pageController = new RemotePageController(tabsController) @@ -31,8 +36,8 @@ export class MultiPageAgent extends PageAgentCore { `Default working language: **${targetLanguage}**` ) - // include initial tab for controlling const includeInitialTab = config.includeInitialTab ?? true + const experimentalIncludeAllTabs = config.experimentalIncludeAllTabs ?? false /** * When the agent is in side-panel and user closed the side-panel. @@ -50,7 +55,7 @@ export class MultiPageAgent extends PageAgentCore { customSystemPrompt: systemPrompt, onBeforeTask: async (agent) => { - await tabsController.init(agent.task, includeInitialTab) + await tabsController.init(agent.task, { includeInitialTab, experimentalIncludeAllTabs }) heartBeatInterval = window.setInterval(() => { chrome.storage.local.set({ diff --git a/packages/extension/src/agent/TabsController.background.ts b/packages/extension/src/agent/TabsController.background.ts index 39c628a..43e5ade 100644 --- a/packages/extension/src/agent/TabsController.background.ts +++ b/packages/extension/src/agent/TabsController.background.ts @@ -114,6 +114,19 @@ export function handleTabControlMessage( return true // async response } + case 'get_window_tabs': { + debug('get_window_tabs') + chrome.tabs + .query({ currentWindow: true }) + .then((tabs) => { + sendResponse({ success: true, tabs }) + }) + .catch((error) => { + sendResponse({ error: error instanceof Error ? error.message : String(error) }) + }) + return true + } + default: sendResponse({ error: `Unknown action: ${action}` }) return diff --git a/packages/extension/src/agent/TabsController.ts b/packages/extension/src/agent/TabsController.ts index 46fabf6..6fe2c1f 100644 --- a/packages/extension/src/agent/TabsController.ts +++ b/packages/extension/src/agent/TabsController.ts @@ -28,16 +28,19 @@ export class TabsController extends EventTarget { private tabs: TabMeta[] = [] private initialTabId: number | null = null private tabGroupId: number | null = null + private experimentalIncludeAllTabs = false private task: string = '' - async init(task: string, includeInitialTab: boolean = true) { - debug('init', task, includeInitialTab) + async init(task: string, options: TabsInitOptions = {}) { + const { includeInitialTab = true, experimentalIncludeAllTabs = false } = options + debug('init', task, options) this.task = task this.tabs = [] this.currentTabId = null this.tabGroupId = null this.initialTabId = null + this.experimentalIncludeAllTabs = experimentalIncludeAllTabs const result = await sendMessage({ type: 'TAB_CONTROL', @@ -50,14 +53,34 @@ export class TabsController extends EventTarget { throw new Error('Failed to get initial tab ID') } - if (includeInitialTab) { + if (experimentalIncludeAllTabs) { + const allTabs = await sendMessage({ + type: 'TAB_CONTROL', + action: 'get_window_tabs', + }) + for (const tab of allTabs.tabs as chrome.tabs.Tab[]) { + if (tab.id && !tab.pinned && isContentScriptAllowed(tab.url)) { + this.tabs.push({ + id: tab.id, + isInitial: tab.id === this.initialTabId, + url: tab.url, + title: tab.title, + status: tab.status, + }) + } + } + if (this.tabs.find((t) => t.id === this.initialTabId)) { + this.currentTabId = this.initialTabId + await this.createTabGroup([this.initialTabId]) + } + } else if (includeInitialTab) { const info = await sendMessage({ type: 'TAB_CONTROL', action: 'get_tab_info', payload: { tabId: this.initialTabId }, }) - if (isContentScriptAllowed(info.url)) { + if (isContentScriptAllowed(info.url) && !info.pinned) { this.currentTabId = this.initialTabId this.tabs.push({ @@ -76,14 +99,13 @@ export class TabsController extends EventTarget { const tabChangeHandler = (message: any): void => { if (message.type !== 'TAB_CHANGE') { - // throw new Error(`[TabsController]: Invalid message type: ${message.type}`) return } if (message.action === 'created') { const tab = message.payload.tab as chrome.tabs.Tab - if (tab.groupId === this.tabGroupId && tab.id != null) { - // Tab created in our controlled group + const shouldTrack = this.experimentalIncludeAllTabs || tab.groupId === this.tabGroupId + if (shouldTrack && tab.id != null) { if (!this.tabs.find((t) => t.id === tab.id)) { this.tabs.push({ id: tab.id, isInitial: false }) } @@ -293,6 +315,11 @@ export class TabsController extends EventTarget { } } +export interface TabsInitOptions { + includeInitialTab?: boolean + experimentalIncludeAllTabs?: boolean +} + export type TabAction = | 'get_active_tab' | 'get_tab_info' @@ -302,6 +329,7 @@ export type TabAction = | 'add_tab_to_group' | 'close_tab' | 'get_tab_title' + | 'get_window_tabs' interface TabMeta { id: number diff --git a/packages/extension/src/agent/useAgent.ts b/packages/extension/src/agent/useAgent.ts index f5596a5..97d0a39 100644 --- a/packages/extension/src/agent/useAgent.ts +++ b/packages/extension/src/agent/useAgent.ts @@ -21,6 +21,7 @@ export interface AdvancedConfig { maxSteps?: number systemInstruction?: string experimentalLlmsTxt?: boolean + experimentalIncludeAllTabs?: boolean disableNamedToolChoice?: boolean } diff --git a/packages/extension/src/entrypoints/main-world.ts b/packages/extension/src/entrypoints/main-world.ts index fd93e68..c7946e8 100644 --- a/packages/extension/src/entrypoints/main-world.ts +++ b/packages/extension/src/entrypoints/main-world.ts @@ -13,6 +13,9 @@ export interface ExecuteConfig { */ includeInitialTab?: boolean + /** Control all unpinned tabs in the window instead of only the tab group. */ + experimentalIncludeAllTabs?: boolean + onStatusChange?: (status: AgentStatus) => void onActivity?: (activity: AgentActivity) => void onHistoryUpdate?: (history: HistoricalEvent[]) => void @@ -87,6 +90,7 @@ export default defineUnlistedScript(() => { model: config.model, apiKey: config.apiKey, includeInitialTab: config.includeInitialTab, + experimentalIncludeAllTabs: config.experimentalIncludeAllTabs, }, }, }, From 52edd78cd46da7cc6d9f7c734407d69b85a70d1b Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:48:52 +0800 Subject: [PATCH 2/5] chore(ext): improve debug logging --- .../src/agent/RemotePageController.background.ts | 4 +--- packages/extension/src/agent/RemotePageController.ts | 4 +--- .../extension/src/agent/TabsController.background.ts | 11 +++++------ packages/extension/src/agent/TabsController.ts | 4 +--- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/extension/src/agent/RemotePageController.background.ts b/packages/extension/src/agent/RemotePageController.background.ts index b75c4cb..8fb89ae 100644 --- a/packages/extension/src/agent/RemotePageController.background.ts +++ b/packages/extension/src/agent/RemotePageController.background.ts @@ -10,9 +10,7 @@ export function handlePageControlMessage( ): true | undefined { const PREFIX = '[RemotePageController.background]' - function debug(...messages: any[]) { - console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages) - } + const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`) const { action, payload, targetTabId } = message diff --git a/packages/extension/src/agent/RemotePageController.ts b/packages/extension/src/agent/RemotePageController.ts index 3a35ddd..0c49f1b 100644 --- a/packages/extension/src/agent/RemotePageController.ts +++ b/packages/extension/src/agent/RemotePageController.ts @@ -4,9 +4,7 @@ import type { TabsController } from './TabsController' const PREFIX = '[RemotePageController]' -function debug(...messages: any[]) { - console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages) -} +const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`) function sendMessage(message: { type: 'PAGE_CONTROL' diff --git a/packages/extension/src/agent/TabsController.background.ts b/packages/extension/src/agent/TabsController.background.ts index 43e5ade..60ba212 100644 --- a/packages/extension/src/agent/TabsController.background.ts +++ b/packages/extension/src/agent/TabsController.background.ts @@ -5,9 +5,8 @@ import type { TabAction } from './TabsController' const PREFIX = '[TabsController.background]' -function debug(...messages: any[]) { - console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages) -} +const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`) +const debugError = console.error.bind(console, `\x1b[91m${PREFIX}\x1b[0m`) export function handleTabControlMessage( message: { type: 'TAB_CONTROL'; action: TabAction; payload: any }, @@ -141,7 +140,7 @@ export function setupTabChangeEvents() { chrome.runtime .sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } }) .catch((error) => { - debug('onCreated error:', error) + debugError('onCreated error:', error) }) }) @@ -154,7 +153,7 @@ export function setupTabChangeEvents() { payload: { tabId, removeInfo }, }) .catch((error) => { - debug('onRemoved error:', error) + debugError('onRemoved error:', error) }) }) @@ -167,7 +166,7 @@ export function setupTabChangeEvents() { payload: { tabId, changeInfo, tab }, }) .catch((error) => { - debug('onUpdated error:', error) + debugError('onUpdated error:', error) }) }) } diff --git a/packages/extension/src/agent/TabsController.ts b/packages/extension/src/agent/TabsController.ts index 6fe2c1f..5492273 100644 --- a/packages/extension/src/agent/TabsController.ts +++ b/packages/extension/src/agent/TabsController.ts @@ -2,9 +2,7 @@ import { isContentScriptAllowed } from './RemotePageController' const PREFIX = '[TabsController]' -function debug(...messages: any[]) { - console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages) -} +const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`) function sendMessage(message: { type: 'TAB_CONTROL' From 312952ec41be0c2fe02fd1d1a2f870249a09b629 Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:24:24 +0800 Subject: [PATCH 3/5] fix(ext): multi window errors --- .../src/agent/TabsController.background.ts | 14 ++++++------- .../extension/src/agent/TabsController.ts | 20 +++++++++++++------ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/extension/src/agent/TabsController.background.ts b/packages/extension/src/agent/TabsController.background.ts index 60ba212..79705d1 100644 --- a/packages/extension/src/agent/TabsController.background.ts +++ b/packages/extension/src/agent/TabsController.background.ts @@ -19,11 +19,10 @@ export function handleTabControlMessage( case 'get_active_tab': { debug('get_active_tab') chrome.tabs - .query({ active: true, currentWindow: true }) + .query({ active: true }) .then((tabs) => { - const tabId = tabs.length > 0 ? tabs[0].id || null : null - debug('get_active_tab: success', tabId) - sendResponse({ success: true, tabId }) + debug('get_active_tab: success', tabs) + sendResponse({ success: true, tab: tabs[0] }) }) .catch((error) => { sendResponse({ error: error instanceof Error ? error.message : String(error) }) @@ -62,7 +61,7 @@ export function handleTabControlMessage( case 'create_tab_group': { debug('create_tab_group', payload) chrome.tabs - .group({ tabIds: payload.tabIds }) + .group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } }) .then((groupId) => { debug('create_tab_group: success', groupId) sendResponse({ success: true, groupId }) @@ -114,9 +113,9 @@ export function handleTabControlMessage( } case 'get_window_tabs': { - debug('get_window_tabs') + debug('get_window_tabs', payload) chrome.tabs - .query({ currentWindow: true }) + .query({ windowId: payload.windowId }) .then((tabs) => { sendResponse({ success: true, tabs }) }) @@ -133,6 +132,7 @@ export function handleTabControlMessage( } export function setupTabChangeEvents() { + // @note It's normal to catch errors here before `TabsController.init()` console.log('[TabsController.background] setupTabChangeEvents') chrome.tabs.onCreated.addListener((tab) => { diff --git a/packages/extension/src/agent/TabsController.ts b/packages/extension/src/agent/TabsController.ts index 5492273..f8f04ea 100644 --- a/packages/extension/src/agent/TabsController.ts +++ b/packages/extension/src/agent/TabsController.ts @@ -23,6 +23,7 @@ function sendMessage(message: { export class TabsController extends EventTarget { currentTabId: number | null = null + private windowId: number | null = null private tabs: TabMeta[] = [] private initialTabId: number | null = null private tabGroupId: number | null = null @@ -34,27 +35,34 @@ export class TabsController extends EventTarget { debug('init', task, options) this.task = task + this.windowId = null this.tabs = [] this.currentTabId = null this.tabGroupId = null this.initialTabId = null this.experimentalIncludeAllTabs = experimentalIncludeAllTabs - const result = await sendMessage({ + const activeTabResult = await sendMessage({ type: 'TAB_CONTROL', action: 'get_active_tab', }) - this.initialTabId = result.tabId + this.initialTabId = activeTabResult.tab?.id + this.windowId = activeTabResult.tab?.windowId - if (!this.initialTabId) { - throw new Error('Failed to get initial tab ID') + if (!this.initialTabId || !this.windowId) { + if (activeTabResult.error) { + throw new Error(activeTabResult.error) + } else { + throw new Error('Failed to get active tab') + } } if (experimentalIncludeAllTabs) { const allTabs = await sendMessage({ type: 'TAB_CONTROL', action: 'get_window_tabs', + payload: { windowId: this.windowId }, }) for (const tab of allTabs.tabs as chrome.tabs.Tab[]) { if (tab.id && !tab.pinned && isContentScriptAllowed(tab.url)) { @@ -82,7 +90,7 @@ export class TabsController extends EventTarget { this.currentTabId = this.initialTabId this.tabs.push({ - id: result.tabId, + id: this.initialTabId, isInitial: true, url: info.url, title: info.title, @@ -229,7 +237,7 @@ export class TabsController extends EventTarget { const result = await sendMessage({ type: 'TAB_CONTROL', action: 'create_tab_group', - payload: { tabIds }, + payload: { tabIds, windowId: this.windowId }, }) if (!result?.success) { From cad033d63bd41dfa55315c52d63a6170b18bf148 Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:25:04 +0800 Subject: [PATCH 4/5] feat(ext): add `experimentalIncludeAllTabs` UI --- packages/extension/src/agent/useAgent.ts | 2 ++ packages/extension/src/components/ConfigPanel.tsx | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/extension/src/agent/useAgent.ts b/packages/extension/src/agent/useAgent.ts index 97d0a39..c6a31bf 100644 --- a/packages/extension/src/agent/useAgent.ts +++ b/packages/extension/src/agent/useAgent.ts @@ -126,6 +126,7 @@ export function useAgent(): UseAgentResult { maxSteps, systemInstruction, experimentalLlmsTxt, + experimentalIncludeAllTabs, disableNamedToolChoice, ...llmConfig }: ExtConfig) => { @@ -139,6 +140,7 @@ export function useAgent(): UseAgentResult { maxSteps, systemInstruction, experimentalLlmsTxt, + experimentalIncludeAllTabs, disableNamedToolChoice, } await chrome.storage.local.set({ advancedConfig }) diff --git a/packages/extension/src/components/ConfigPanel.tsx b/packages/extension/src/components/ConfigPanel.tsx index 0a0c338..d7a2d9f 100644 --- a/packages/extension/src/components/ConfigPanel.tsx +++ b/packages/extension/src/components/ConfigPanel.tsx @@ -36,6 +36,9 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) { const [experimentalLlmsTxt, setExperimentalLlmsTxt] = useState( config?.experimentalLlmsTxt ?? false ) + const [experimentalIncludeAllTabs, setExperimentalIncludeAllTabs] = useState( + config?.experimentalIncludeAllTabs ?? false + ) const [disableNamedToolChoice, setDisableNamedToolChoice] = useState( config?.disableNamedToolChoice ?? false ) @@ -54,6 +57,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) { setMaxSteps(config?.maxSteps) setSystemInstruction(config?.systemInstruction ?? '') setExperimentalLlmsTxt(config?.experimentalLlmsTxt ?? false) + setExperimentalIncludeAllTabs(config?.experimentalIncludeAllTabs ?? false) setDisableNamedToolChoice(config?.disableNamedToolChoice ?? false) }, [config]) @@ -100,6 +104,7 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) { maxSteps: maxSteps || undefined, systemInstruction: systemInstruction || undefined, experimentalLlmsTxt, + experimentalIncludeAllTabs, disableNamedToolChoice, }) } finally { @@ -285,6 +290,14 @@ export function ConfigPanel({ config, onSave, onClose }: ConfigPanelProps) { Experimental llms.txt support + + )} From 49b137981cf9f395895e72465003083aadbe3cbf Mon Sep 17 00:00:00 2001 From: Simon <10131203+gaomeng1900@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:40:16 +0800 Subject: [PATCH 5/5] fix(ext): tab events do not work for content scripts --- .../src/agent/TabsController.background.ts | 57 ++++---- .../extension/src/agent/TabsController.ts | 132 +++++++++++------- .../extension/src/entrypoints/background.ts | 4 +- 3 files changed, 112 insertions(+), 81 deletions(-) diff --git a/packages/extension/src/agent/TabsController.background.ts b/packages/extension/src/agent/TabsController.background.ts index 79705d1..5b4baff 100644 --- a/packages/extension/src/agent/TabsController.background.ts +++ b/packages/extension/src/agent/TabsController.background.ts @@ -6,7 +6,6 @@ import type { TabAction } from './TabsController' const PREFIX = '[TabsController.background]' const debug = console.debug.bind(console, `\x1b[90m${PREFIX}\x1b[0m`) -const debugError = console.error.bind(console, `\x1b[91m${PREFIX}\x1b[0m`) export function handleTabControlMessage( message: { type: 'TAB_CONTROL'; action: TabAction; payload: any }, @@ -131,42 +130,40 @@ export function handleTabControlMessage( } } -export function setupTabChangeEvents() { - // @note It's normal to catch errors here before `TabsController.init()` - console.log('[TabsController.background] setupTabChangeEvents') +const tabEventPorts = new Set() + +function broadcastTabEvent(message: object) { + for (const port of tabEventPorts) { + port.postMessage(message) + } +} + +/** + * Port-based tab events: agents connect via `chrome.runtime.connect({ name: 'tab-events' })` + * and receive tab change events through the port. Works for both extension pages and content scripts. + */ +export function setupTabEventsPort() { + chrome.runtime.onConnect.addListener((port) => { + if (port.name !== 'tab-events') return + + debug('port connected', port.sender?.tab?.id ?? port.sender?.url) + tabEventPorts.add(port) + + port.onDisconnect.addListener(() => { + debug('port disconnected') + tabEventPorts.delete(port) + }) + }) chrome.tabs.onCreated.addListener((tab) => { - debug('onCreated', tab) - chrome.runtime - .sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } }) - .catch((error) => { - debugError('onCreated error:', error) - }) + broadcastTabEvent({ action: 'created', payload: { tab } }) }) chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { - debug('onRemoved', tabId, removeInfo) - chrome.runtime - .sendMessage({ - type: 'TAB_CHANGE', - action: 'removed', - payload: { tabId, removeInfo }, - }) - .catch((error) => { - debugError('onRemoved error:', error) - }) + broadcastTabEvent({ action: 'removed', payload: { tabId, removeInfo } }) }) chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { - debug('onUpdated', tabId, changeInfo) - chrome.runtime - .sendMessage({ - type: 'TAB_CHANGE', - action: 'updated', - payload: { tabId, changeInfo, tab }, - }) - .catch((error) => { - debugError('onUpdated error:', error) - }) + broadcastTabEvent({ action: 'updated', payload: { tabId, changeInfo, tab } }) }) } diff --git a/packages/extension/src/agent/TabsController.ts b/packages/extension/src/agent/TabsController.ts index f8f04ea..b21e7af 100644 --- a/packages/extension/src/agent/TabsController.ts +++ b/packages/extension/src/agent/TabsController.ts @@ -20,9 +20,13 @@ function sendMessage(message: { * - live in the agent env (extension page or content script) * - no chrome apis. call sw for tab operations */ -export class TabsController extends EventTarget { +export class TabsController { currentTabId: number | null = null + private disposed = false + private port: chrome.runtime.Port | null = null + private portRetries = 0 + private windowId: number | null = null private tabs: TabMeta[] = [] private initialTabId: number | null = null @@ -34,13 +38,21 @@ export class TabsController extends EventTarget { const { includeInitialTab = true, experimentalIncludeAllTabs = false } = options debug('init', task, options) - this.task = task + if (this.disposed) { + throw new Error('TabsController already disposed') + } + + this.currentTabId = null + this.disposed = false + this.port = null + this.portRetries = 0 + this.windowId = null this.tabs = [] - this.currentTabId = null this.tabGroupId = null this.initialTabId = null this.experimentalIncludeAllTabs = experimentalIncludeAllTabs + this.task = task const activeTabResult = await sendMessage({ type: 'TAB_CONTROL', @@ -58,6 +70,8 @@ export class TabsController extends EventTarget { } } + this.connectTabEvents() + if (experimentalIncludeAllTabs) { const allTabs = await sendMessage({ type: 'TAB_CONTROL', @@ -102,51 +116,6 @@ export class TabsController extends EventTarget { } await this.updateCurrentTabId(this.currentTabId) - - const tabChangeHandler = (message: any): void => { - if (message.type !== 'TAB_CHANGE') { - return - } - - if (message.action === 'created') { - const tab = message.payload.tab as chrome.tabs.Tab - const shouldTrack = this.experimentalIncludeAllTabs || tab.groupId === this.tabGroupId - if (shouldTrack && tab.id != null) { - if (!this.tabs.find((t) => t.id === tab.id)) { - this.tabs.push({ id: tab.id, isInitial: false }) - } - this.switchToTab(tab.id) - } - } else if (message.action === 'removed') { - const { tabId } = message.payload as { tabId: number } - const targetTab = this.tabs.find((t) => t.id === tabId) - if (targetTab) { - this.tabs = this.tabs.filter((t) => t.id !== tabId) - if (this.currentTabId === tabId) { - const newCurrentTab = this.tabs[this.tabs.length - 1] || null - if (newCurrentTab) { - this.switchToTab(newCurrentTab.id) - } else { - this.updateCurrentTabId(null) - } - } - } - } else if (message.action === 'updated') { - const { tabId, tab } = message.payload as { tabId: number; tab: chrome.tabs.Tab } - const targetTab = this.tabs.find((t) => t.id === tabId) - if (targetTab) { - targetTab.url = tab.url - targetTab.title = tab.title - targetTab.status = tab.status - } - } - } - - chrome.runtime.onMessage.addListener(tabChangeHandler) - - this.addEventListener('dispose', () => { - chrome.runtime.onMessage.removeListener(tabChangeHandler) - }) } async openNewTab(url: string): Promise { @@ -316,8 +285,73 @@ export class TabsController extends EventTarget { await waitUntil(() => tab.status === 'complete', 4_000) } + /** + * Connect to background SW via port to receive tab change events. + * + * @note Port is 1:1 (runtime.connect → background SW has no frames), + * so onDisconnect fires exactly once and we can safely reconnect. + * Reconnection may miss events during the gap. + * TODO: refresh this.tabs from background after reconnect to stay consistent. + */ + private connectTabEvents() { + this.port = chrome.runtime.connect({ name: 'tab-events' }) + + this.port.onMessage.addListener((message: any) => { + if (this.disposed) return + this.portRetries = 0 + + if (message.action === 'created') { + const tab = message.payload.tab as chrome.tabs.Tab + const shouldTrack = this.experimentalIncludeAllTabs || tab.groupId === this.tabGroupId + if (shouldTrack && tab.id != null) { + if (!this.tabs.find((t) => t.id === tab.id)) { + this.tabs.push({ id: tab.id, isInitial: false }) + } + this.switchToTab(tab.id) + } + } else if (message.action === 'removed') { + const { tabId } = message.payload as { tabId: number } + const targetTab = this.tabs.find((t) => t.id === tabId) + if (targetTab) { + this.tabs = this.tabs.filter((t) => t.id !== tabId) + if (this.currentTabId === tabId) { + const newCurrentTab = this.tabs[this.tabs.length - 1] || null + if (newCurrentTab) { + this.switchToTab(newCurrentTab.id) + } else { + this.updateCurrentTabId(null) + } + } + } + } else if (message.action === 'updated') { + const { tabId, tab } = message.payload as { tabId: number; tab: chrome.tabs.Tab } + const targetTab = this.tabs.find((t) => t.id === tabId) + if (targetTab) { + targetTab.url = tab.url + targetTab.title = tab.title + targetTab.status = tab.status + } + } + }) + + this.port.onDisconnect.addListener(() => { + this.port = null + if (this.disposed) return + if (this.portRetries >= 7) { + console.error(PREFIX, 'tab events port failed after 3 retries, giving up') + return + } + debug('port disconnected, reconnecting...') + this.portRetries++ + this.connectTabEvents() + }) + } + dispose() { - this.dispatchEvent(new Event('dispose')) + debug('dispose') + this.disposed = true + this.port?.disconnect() + this.port = null } } diff --git a/packages/extension/src/entrypoints/background.ts b/packages/extension/src/entrypoints/background.ts index 9c26f96..a83fde3 100644 --- a/packages/extension/src/entrypoints/background.ts +++ b/packages/extension/src/entrypoints/background.ts @@ -1,12 +1,12 @@ import { handlePageControlMessage } from '@/agent/RemotePageController.background' -import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsController.background' +import { handleTabControlMessage, setupTabEventsPort } from '@/agent/TabsController.background' export default defineBackground(() => { console.log('[Background] Service Worker started') // tab change events - setupTabChangeEvents() + setupTabEventsPort() // generate user auth token