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