fix(ext): fix multi-thread logic; extensive logging and error handling

This commit is contained in:
Simon
2026-02-11 19:51:19 +08:00
parent fcb9ec4e57
commit 7c87c90258
9 changed files with 268 additions and 116 deletions

View File

@@ -74,6 +74,11 @@ export class MultiPageAgent extends PageAgentCore {
}) })
}, },
onBeforeStep: async (agent) => {
// make sure the current tab is loaded before the step starts
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!)
},
onDispose: () => { onDispose: () => {
if (heartBeatInterval) { if (heartBeatInterval) {
window.clearInterval(heartBeatInterval) window.clearInterval(heartBeatInterval)

View File

@@ -8,13 +8,21 @@ export function handlePageControlMessage(
sender: chrome.runtime.MessageSender, sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void sendResponse: (response: unknown) => void
): true | undefined { ): true | undefined {
const PREFIX = '[RemotePageController.background]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
const { action, payload, targetTabId } = message const { action, payload, targetTabId } = message
if (action === 'get_my_tab_id') { if (action === 'get_my_tab_id') {
debug('get_my_tab_id', sender.tab?.id)
sendResponse({ tabId: sender.tab?.id || null }) sendResponse({ tabId: sender.tab?.id || null })
return return
} }
// proxy to content script
chrome.tabs chrome.tabs
.sendMessage(targetTabId, { .sendMessage(targetTabId, {
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
@@ -25,6 +33,7 @@ export function handlePageControlMessage(
sendResponse(result) sendResponse(result)
}) })
.catch((error) => { .catch((error) => {
console.error(PREFIX, error)
sendResponse({ sendResponse({
success: false, success: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),

View File

@@ -12,10 +12,14 @@ export function initPageController() {
.then((response) => { .then((response) => {
return (response as { tabId: number | null }).tabId return (response as { tabId: number | null }).tabId
}) })
.catch((error) => {
console.error('[RemotePageController.ContentScript]: Failed to get my tab id', error)
return null
})
function getPC(): PageController { function getPC(): PageController {
if (!pageController) { if (!pageController) {
pageController = new PageController({ enableMask: false }) pageController = new PageController({ enableMask: false, viewportExpansion: 400 })
} }
return pageController return pageController
} }

View File

@@ -2,13 +2,31 @@ import type { BrowserState } from '@page-agent/page-controller'
import type { TabsController } from './TabsController' import type { TabsController } from './TabsController'
const PREFIX = '[RemotePageController]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
function sendMessage(message: {
type: 'PAGE_CONTROL'
action: string
targetTabId: number
payload?: any
}): Promise<any> {
return chrome.runtime.sendMessage(message).catch((error) => {
console.error(PREFIX, message.action, error)
return null
})
}
/** /**
* Agent side page controller. * Agent side page controller.
* - live in the agent env (extension page or content script) * - live in the agent env (extension page or content script)
* - communicates with remote PageController via sw * - communicates with remote PageController via sw
*/ */
export class RemotePageController { export class RemotePageController {
private tabsController: TabsController tabsController: TabsController
constructor(tabsController: TabsController) { constructor(tabsController: TabsController) {
this.tabsController = tabsController this.tabsController = tabsController
@@ -18,46 +36,46 @@ export class RemotePageController {
return this.tabsController.currentTabId return this.tabsController.currentTabId
} }
async getCurrentUrl(): Promise<string> { private async getCurrentUrl(): Promise<string> {
if (!this.currentTabId) return '' if (!this.currentTabId) return ''
const { url } = await this.tabsController.getTabInfo(this.currentTabId) const { url } = await this.tabsController.getTabInfo(this.currentTabId)
return url || '' return url || ''
} }
async getCurrentTitle(): Promise<string> { private async getCurrentTitle(): Promise<string> {
if (!this.currentTabId) return '' if (!this.currentTabId) return ''
const { title } = await this.tabsController.getTabInfo(this.currentTabId) const { title } = await this.tabsController.getTabInfo(this.currentTabId)
return title || '' return title || ''
} }
get currentTabTitle(): Promise<string> {
return this.getCurrentTitle()
}
async getLastUpdateTime(): Promise<number> { async getLastUpdateTime(): Promise<number> {
if (!this.currentTabId) throw new Error('tabsController not initialized.') if (!this.currentTabId) throw new Error('tabsController not initialized.')
return sendMessage({
return await chrome.runtime.sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: 'get_last_update_time', action: 'get_last_update_time',
targetTabId: this.currentTabId, targetTabId: this.currentTabId,
}) })
} }
// getBrowserState
async getBrowserState(): Promise<BrowserState> { async getBrowserState(): Promise<BrowserState> {
let browserState = {} as BrowserState if (!this.currentTabId) throw new Error('tabsController not initialized.')
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) { let browserState = {} as BrowserState
debug('getBrowserState', this.currentTabId)
const currentUrl = await this.getCurrentUrl()
const currentTitle = await this.getCurrentTitle()
if (!this.currentTabId || !isContentScriptAllowed(currentUrl)) {
browserState = { browserState = {
url: await this.currentTabUrl, url: currentUrl,
title: await this.currentTabTitle, title: currentTitle,
header: '', header: '',
content: '(empty page)', content: '(empty page. either current page is not readable or not loaded yet.)',
footer: '', footer: '',
} }
} else { } else {
browserState = await chrome.runtime.sendMessage({ browserState = await sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: 'get_browser_state', action: 'get_browser_state',
targetTabId: this.currentTabId, targetTabId: this.currentTabId,
@@ -67,61 +85,58 @@ export class RemotePageController {
const sum = await this.tabsController.summarizeTabs() const sum = await this.tabsController.summarizeTabs()
browserState.header = sum + '\n\n' + (browserState.header || '') browserState.header = sum + '\n\n' + (browserState.header || '')
debug('getBrowserState: success', this.currentTabId, browserState)
return browserState return browserState
} }
// updateTree
async updateTree(): Promise<void> { async updateTree(): Promise<void> {
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) { if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
return return
} }
await chrome.runtime.sendMessage({ await sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: 'update_tree', action: 'update_tree',
targetTabId: this.currentTabId, targetTabId: this.currentTabId,
}) })
} }
// cleanUpHighlights
async cleanUpHighlights(): Promise<void> { async cleanUpHighlights(): Promise<void> {
if (!this.currentTabId || !isContentScriptAllowed(await this.currentTabUrl)) { if (!this.currentTabId || !isContentScriptAllowed(await this.getCurrentUrl())) {
return return
} }
await chrome.runtime.sendMessage({ await sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: 'clean_up_highlights', action: 'clean_up_highlights',
targetTabId: this.currentTabId, targetTabId: this.currentTabId,
}) })
} }
// clickElement
async clickElement(...args: any[]): Promise<DomActionReturn> { async clickElement(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('click_element', args) const res = await this.remoteCallDomAction('click_element', args)
// @note may cause page navigation, wait for 1 second to ensure the page loading started
await new Promise((resolve) => setTimeout(resolve, 1000))
return res
} }
// inputText
async inputText(...args: any[]): Promise<DomActionReturn> { async inputText(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('input_text', args) return this.remoteCallDomAction('input_text', args)
} }
// selectOption
async selectOption(...args: any[]): Promise<DomActionReturn> { async selectOption(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('select_option', args) return this.remoteCallDomAction('select_option', args)
} }
// scroll
async scroll(...args: any[]): Promise<DomActionReturn> { async scroll(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('scroll', args) return this.remoteCallDomAction('scroll', args)
} }
// scrollHorizontally
async scrollHorizontally(...args: any[]): Promise<DomActionReturn> { async scrollHorizontally(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('scroll_horizontally', args) return this.remoteCallDomAction('scroll_horizontally', args)
} }
// executeJavascript
async executeJavascript(...args: any[]): Promise<DomActionReturn> { async executeJavascript(...args: any[]): Promise<DomActionReturn> {
return this.remoteCallDomAction('execute_javascript', args) return this.remoteCallDomAction('execute_javascript', args)
} }
@@ -133,35 +148,26 @@ export class RemotePageController {
/** @note Managed by content script via storage polling. */ /** @note Managed by content script via storage polling. */
dispose(): void {} dispose(): void {}
private async preCheck() {
if (!this.currentTabId) {
return 'RemotePageController not initialized.'
}
if (!isContentScriptAllowed(await this.currentTabUrl)) {
return 'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.'
}
return null
}
private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> { private async remoteCallDomAction(action: string, payload: any[]): Promise<DomActionReturn> {
const preCheckError = await this.preCheck() if (!this.currentTabId) {
if (preCheckError) { return { success: false, message: 'RemotePageController not initialized.' }
return { success: false, message: preCheckError }
} }
return await chrome.runtime.sendMessage({ if (!isContentScriptAllowed(await this.getCurrentUrl())) {
return {
success: false,
message:
'Operation not allowed on this page. Use open_new_tab to navigate to a web page first.',
}
}
return sendMessage({
type: 'PAGE_CONTROL', type: 'PAGE_CONTROL',
action: action, action: action,
targetTabId: this.currentTabId!, targetTabId: this.currentTabId!,
payload, payload,
}) })
} }
private get currentTabUrl(): Promise<string> {
return this.getCurrentUrl()
}
} }
interface DomActionReturn { interface DomActionReturn {

View File

@@ -3,6 +3,12 @@
*/ */
import type { TabAction } from './TabsController' import type { TabAction } from './TabsController'
const PREFIX = '[TabsController.background]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
export function handleTabControlMessage( export function handleTabControlMessage(
message: { type: 'TAB_CONTROL'; action: TabAction; payload: any }, message: { type: 'TAB_CONTROL'; action: TabAction; payload: any },
sender: chrome.runtime.MessageSender, sender: chrome.runtime.MessageSender,
@@ -12,10 +18,12 @@ export function handleTabControlMessage(
switch (action as TabAction) { switch (action as TabAction) {
case 'get_active_tab': { case 'get_active_tab': {
debug('get_active_tab')
chrome.tabs chrome.tabs
.query({ active: true, currentWindow: true }) .query({ active: true, currentWindow: true })
.then((tabs) => { .then((tabs) => {
const tabId = tabs.length > 0 ? tabs[0].id || null : null const tabId = tabs.length > 0 ? tabs[0].id || null : null
debug('get_active_tab: success', tabId)
sendResponse({ success: true, tabId }) sendResponse({ success: true, tabId })
}) })
.catch((error) => { .catch((error) => {
@@ -25,11 +33,12 @@ export function handleTabControlMessage(
} }
case 'get_tab_info': { case 'get_tab_info': {
debug('get_tab_info', payload)
chrome.tabs chrome.tabs
.get(payload.tabId) .get(payload.tabId)
.then((tab) => { .then((tab) => {
const result = { title: tab.title || '', url: tab.url || '' } debug('get_tab_info: success', tab)
sendResponse(result) sendResponse(tab)
}) })
.catch((error) => { .catch((error) => {
sendResponse({ error: error instanceof Error ? error.message : String(error) }) sendResponse({ error: error instanceof Error ? error.message : String(error) })
@@ -38,10 +47,11 @@ export function handleTabControlMessage(
} }
case 'open_new_tab': { case 'open_new_tab': {
debug('open_new_tab', payload)
chrome.tabs chrome.tabs
.create({ url: payload.url, active: false }) .create({ url: payload.url, active: false })
.then((newTab) => { .then((newTab) => {
// @todo: wait for tab to be fully loaded debug('open_new_tab: success', newTab)
sendResponse({ success: true, tabId: newTab.id, windowId: newTab.windowId }) sendResponse({ success: true, tabId: newTab.id, windowId: newTab.windowId })
}) })
.catch((error) => { .catch((error) => {
@@ -51,20 +61,22 @@ export function handleTabControlMessage(
} }
case 'create_tab_group': { case 'create_tab_group': {
debug('create_tab_group', payload)
chrome.tabs chrome.tabs
.group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } }) .group({ tabIds: payload.tabIds, createProperties: { windowId: payload.windowId } })
.then((groupId) => { .then((groupId) => {
console.log('Created tab group', groupId) debug('create_tab_group: success', groupId)
sendResponse({ success: true, groupId }) sendResponse({ success: true, groupId })
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to create tab group', error) console.error(PREFIX, 'Failed to create tab group', error)
sendResponse({ error: error instanceof Error ? error.message : String(error) }) sendResponse({ error: error instanceof Error ? error.message : String(error) })
}) })
return true // async response return true // async response
} }
case 'update_tab_group': { case 'update_tab_group': {
debug('update_tab_group', payload)
chrome.tabGroups chrome.tabGroups
.update(payload.groupId, payload.properties) .update(payload.groupId, payload.properties)
.then(() => { .then(() => {
@@ -77,6 +89,7 @@ export function handleTabControlMessage(
} }
case 'add_tab_to_group': { case 'add_tab_to_group': {
debug('add_tab_to_group', payload)
chrome.tabs chrome.tabs
.group({ tabIds: payload.tabId, groupId: payload.groupId }) .group({ tabIds: payload.tabId, groupId: payload.groupId })
.then(() => { .then(() => {
@@ -89,6 +102,7 @@ export function handleTabControlMessage(
} }
case 'close_tab': { case 'close_tab': {
debug('close_tab', payload)
chrome.tabs chrome.tabs
.remove(payload.tabId) .remove(payload.tabId)
.then(() => { .then(() => {
@@ -107,17 +121,40 @@ export function handleTabControlMessage(
} }
export function setupTabChangeEvents() { export function setupTabChangeEvents() {
console.log('[TabsController.background] setupTabChangeEvents')
chrome.tabs.onCreated.addListener((tab) => { chrome.tabs.onCreated.addListener((tab) => {
console.debug('[Background] Tab created', tab) debug('onCreated', tab)
chrome.runtime.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } }) chrome.runtime
.sendMessage({ type: 'TAB_CHANGE', action: 'created', payload: { tab } })
.catch((error) => {
debug('onCreated error:', error)
})
}) })
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
console.debug('[Background] Tab removed', tabId, removeInfo) debug('onRemoved', tabId, removeInfo)
chrome.runtime.sendMessage({ chrome.runtime
type: 'TAB_CHANGE', .sendMessage({
action: 'removed', type: 'TAB_CHANGE',
payload: { tabId, removeInfo }, action: 'removed',
}) payload: { tabId, removeInfo },
})
.catch((error) => {
debug('onRemoved error:', error)
})
})
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) => {
debug('onUpdated error:', error)
})
}) })
} }

View File

@@ -1,3 +1,20 @@
const PREFIX = '[TabsController]'
function debug(...messages: any[]) {
console.debug(`\x1b[90m${PREFIX}\x1b[0m`, ...messages)
}
function sendMessage(message: {
type: 'TAB_CONTROL'
action: TabAction
payload?: any
}): Promise<any> {
return chrome.runtime.sendMessage(message).catch((error) => {
console.error(PREFIX, message.action, error)
return null
})
}
/** /**
* Controller for managing browser tabs. * Controller for managing browser tabs.
* - live in the agent env (extension page or content script) * - live in the agent env (extension page or content script)
@@ -13,6 +30,8 @@ export class TabsController extends EventTarget {
private windowId: number | null = null private windowId: number | null = null
async init(task: string, includeInitialTab: boolean = true) { async init(task: string, includeInitialTab: boolean = true) {
debug('init', task, includeInitialTab)
this.task = task this.task = task
this.tabs = [] this.tabs = []
this.currentTabId = null this.currentTabId = null
@@ -20,7 +39,7 @@ export class TabsController extends EventTarget {
this.initialTabId = null this.initialTabId = null
this.windowId = null this.windowId = null
const result = await chrome.runtime.sendMessage({ const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'get_active_tab', action: 'get_active_tab',
}) })
@@ -33,9 +52,20 @@ export class TabsController extends EventTarget {
if (includeInitialTab) { if (includeInitialTab) {
this.currentTabId = this.initialTabId this.currentTabId = this.initialTabId
// update tab status immediately
const info = await sendMessage({
type: 'TAB_CONTROL',
action: 'get_tab_info',
payload: { tabId: this.initialTabId },
})
this.tabs.push({ this.tabs.push({
id: result.tabId, id: result.tabId,
isInitial: true, isInitial: true,
url: info.url,
title: info.title,
status: info.status,
}) })
} }
@@ -70,6 +100,14 @@ export class TabsController extends EventTarget {
} }
} }
} }
} 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
}
} }
} }
@@ -80,8 +118,10 @@ export class TabsController extends EventTarget {
}) })
} }
async openNewTab(url: string): Promise<{ success: boolean; tabId: number; message: string }> { async openNewTab(url: string): Promise<string> {
const result = await chrome.runtime.sendMessage({ debug('openNewTab', url)
const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'open_new_tab', action: 'open_new_tab',
payload: { url }, payload: { url },
@@ -104,7 +144,7 @@ export class TabsController extends EventTarget {
await this.switchToTab(tabId) await this.switchToTab(tabId)
if (!this.tabGroupId) { if (!this.tabGroupId) {
const result = await chrome.runtime.sendMessage({ const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'create_tab_group', action: 'create_tab_group',
payload: { tabIds: [tabId], windowId: this.windowId }, payload: { tabIds: [tabId], windowId: this.windowId },
@@ -118,7 +158,7 @@ export class TabsController extends EventTarget {
this.tabGroupId = groupId this.tabGroupId = groupId
await chrome.runtime.sendMessage({ await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'update_tab_group', action: 'update_tab_group',
payload: { payload: {
@@ -131,57 +171,43 @@ export class TabsController extends EventTarget {
}, },
}) })
} else { } else {
await chrome.runtime.sendMessage({ await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'add_tab_to_group', action: 'add_tab_to_group',
payload: { tabId: result.tabId, groupId: this.tabGroupId }, payload: { tabId: result.tabId, groupId: this.tabGroupId },
}) })
} }
// wait for the new tab to be fully loaded await this.waitUntilTabLoaded(tabId)
// @todo
await new Promise((resolve) => setTimeout(resolve, 500))
return { return `✅ Opened new tab ID ${tabId} with URL ${url}`
success: true,
tabId,
message: `Opened new tab ID ${tabId} with URL ${url}`,
}
} }
async switchToTab(tabId: number): Promise<{ success: boolean; message: string }> { async switchToTab(tabId: number): Promise<string> {
debug('switchToTab', tabId)
const targetTab = this.tabs.find((t) => t.id === tabId) const targetTab = this.tabs.find((t) => t.id === tabId)
if (!targetTab) { if (!targetTab) {
return { throw new Error(`Tab ID ${tabId} not found in tab list.`)
success: false,
message: `Tab ID ${tabId} not found in tab list.`,
}
} }
await this.updateCurrentTabId(tabId) await this.updateCurrentTabId(tabId)
return { return `✅ Switched to tab ID ${tabId}.`
success: true,
message: `Switched to tab ID ${tabId}.`,
}
} }
async closeTab(tabId: number): Promise<{ success: boolean; message: string }> { async closeTab(tabId: number): Promise<string> {
debug('closeTab', tabId)
const targetTab = this.tabs.find((t) => t.id === tabId) const targetTab = this.tabs.find((t) => t.id === tabId)
if (!targetTab) { if (!targetTab) {
return { throw new Error(`Tab ID ${tabId} not found in tab list.`)
success: false,
message: `Tab ID ${tabId} not found in tab list.`,
}
} }
if (targetTab.isInitial) { if (targetTab.isInitial) {
return { throw new Error(`Cannot close the initial tab ID ${tabId}.`)
success: false,
message: `Cannot close the initial tab ID ${tabId}.`,
}
} }
const result = await chrome.runtime.sendMessage({ const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'close_tab', action: 'close_tab',
payload: { tabId }, payload: { tabId },
@@ -198,29 +224,39 @@ export class TabsController extends EventTarget {
} }
} }
return { return `✅ Closed tab ID ${tabId}.`
success: true,
message: `Closed tab ID ${tabId}.`,
}
} else { } else {
return { throw new Error(`Failed to close tab ID ${tabId}: ${result.error}`)
success: false,
message: `Failed to close tab ID ${tabId}: ${result.error}`,
}
} }
} }
async updateCurrentTabId(tabId: number | null) { async updateCurrentTabId(tabId: number | null) {
debug('updateCurrentTabId', tabId)
this.currentTabId = tabId this.currentTabId = tabId
await chrome.storage.local.set({ currentTabId: tabId }) await chrome.storage.local.set({ currentTabId: tabId })
} }
async getTabInfo(tabId: number): Promise<{ title: string; url: string }> { async getTabInfo(tabId: number): Promise<{ title: string; url: string }> {
const result = await chrome.runtime.sendMessage({ // use cached tab info if available
const tabMeta = this.tabs.find((t) => t.id === tabId)
if (tabMeta && tabMeta.url && tabMeta.title) {
return { title: tabMeta.title, url: tabMeta.url }
}
// otherwise, pull the latest tab info from the background script
debug('getTabInfo: pulling from background script', tabId)
const result = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
action: 'get_tab_info', action: 'get_tab_info',
payload: { tabId }, payload: { tabId },
}) })
if (tabMeta) {
tabMeta.url = result.url
tabMeta.title = result.title
}
return result return result
} }
@@ -239,6 +275,17 @@ export class TabsController extends EventTarget {
return summaries.join('\n') return summaries.join('\n')
} }
async waitUntilTabLoaded(tabId: number): Promise<void> {
const tab = this.tabs.find((t) => t.id === tabId)
if (!tab) throw new Error(`Tab ID ${tabId} not found in tab list.`)
if (tab.status === 'unloaded') throw new Error(`Tab ID ${tabId} is unloaded.`)
if (tab.status === 'complete') return
debug('waitUntilTabLoaded', tabId)
await waitUntil(() => tab.status === 'complete', 4_000)
}
dispose() { dispose() {
this.dispatchEvent(new Event('dispose')) this.dispatchEvent(new Event('dispose'))
} }
@@ -257,6 +304,9 @@ export type TabAction =
interface TabMeta { interface TabMeta {
id: number id: number
isInitial: boolean isInitial: boolean
url?: string
title?: string
status?: 'loading' | 'unloaded' | 'complete'
} }
const TAB_GROUP_COLORS = [ const TAB_GROUP_COLORS = [
@@ -275,3 +325,33 @@ type TabGroupColor = (typeof TAB_GROUP_COLORS)[number]
function randomColor(): TabGroupColor { function randomColor(): TabGroupColor {
return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)] return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)]
} }
/**
* Wait until condition becomes true
* @returns Returns when condition becomes true, throws otherwise
* @param timeoutMS Timeout in milliseconds, default 1 minutes, throws error on timeout
* @param error Error object to reject on timeout. If not provided, will resolve with false
*/
export async function waitUntil(
check: () => boolean | Promise<boolean>,
timeoutMS = 60_000,
error?: string
): Promise<boolean> {
if (await check()) return true
return new Promise((resolve, reject) => {
const start = Date.now()
const poll = async () => {
if (await check()) return resolve(true)
if (Date.now() - start > timeoutMS) {
if (error) {
return reject(new Error(error))
} else {
return resolve(false)
}
}
setTimeout(poll, 100)
}
setTimeout(poll, 100)
})
}

View File

@@ -31,8 +31,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
}), }),
execute: async (input: unknown) => { execute: async (input: unknown) => {
const { url } = input as { url: string } const { url } = input as { url: string }
const result = await tabsController.openNewTab(url) try {
return result.message return await tabsController.openNewTab(url)
} catch (error) {
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
}
}, },
}, },
@@ -44,7 +47,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
}), }),
execute: async (input: unknown) => { execute: async (input: unknown) => {
const { tab_id } = input as { tab_id: number } const { tab_id } = input as { tab_id: number }
return (await tabsController.switchToTab(tab_id)).message try {
return await tabsController.switchToTab(tab_id)
} catch (error) {
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
}
}, },
}, },
@@ -56,7 +63,11 @@ export function createTabTools(tabsController: TabsController): Record<string, T
}), }),
execute: async (input: unknown) => { execute: async (input: unknown) => {
const { tab_id } = input as { tab_id: number } const { tab_id } = input as { tab_id: number }
return (await tabsController.closeTab(tab_id)).message try {
return await tabsController.closeTab(tab_id)
} catch (error) {
return `❌ Failed: ${error instanceof Error ? error.message : String(error)}`
}
}, },
}, },
} }

View File

@@ -4,6 +4,10 @@ import { handleTabControlMessage, setupTabChangeEvents } from '@/agent/TabsContr
export default defineBackground(() => { export default defineBackground(() => {
console.log('[Background] Service Worker started') console.log('[Background] Service Worker started')
// tab change events
setupTabChangeEvents()
// generate user auth token // generate user auth token
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => { chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {
@@ -13,10 +17,6 @@ export default defineBackground(() => {
chrome.storage.local.set({ PageAgentExtUserAuthToken: userAuthToken }) chrome.storage.local.set({ PageAgentExtUserAuthToken: userAuthToken })
}) })
// setup
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
// message proxy // message proxy
chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => { chrome.runtime.onMessage.addListener((message, sender, sendResponse): true | undefined => {
@@ -30,7 +30,7 @@ export default defineBackground(() => {
} }
}) })
// tab change events // setup
setupTabChangeEvents() chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
}) })

View File

@@ -6,7 +6,7 @@ const DEBUG_PREFIX = '[Content]'
export default defineContentScript({ export default defineContentScript({
matches: ['<all_urls>'], matches: ['<all_urls>'],
runAt: 'document_idle', runAt: 'document_end',
main() { main() {
console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`) console.debug(`${DEBUG_PREFIX} Loaded on ${window.location.href}`)