feat: multi tabs control

This commit is contained in:
Simon
2026-01-24 19:29:27 +08:00
parent 2aa9c3b978
commit fa5ab9d567
17 changed files with 2303 additions and 1061 deletions

View File

@@ -4,7 +4,11 @@
* This class implements the same interface as PageController but forwards
* all method calls via RPC to the real PageController running in ContentScript.
* This allows PageAgentCore to work transparently with remote DOM operations.
*
* Tab targeting is managed externally by TabsManager via setTargetTab().
*/
import type { PageController } from '@page-agent/page-controller'
import type {
ActionResult,
BrowserState,
@@ -13,6 +17,32 @@ import type {
} from '../messaging/protocol'
import { type RPCClient, createRPCClient } from '../messaging/rpc'
const DEBUG_PREFIX = '[RemotePageController]'
/**
* Check if a URL can run content scripts.
* Chrome extensions cannot inject content scripts into certain pages.
*/
export function isContentScriptAllowed(url: string | undefined): boolean {
if (!url) return false
// Restricted URL patterns
const restrictedPatterns = [
/^chrome:\/\//,
/^chrome-extension:\/\//,
/^about:/,
/^edge:\/\//,
/^brave:\/\//,
/^opera:\/\//,
/^vivaldi:\/\//,
/^file:\/\//,
/^view-source:/,
/^devtools:\/\//,
]
return !restrictedPatterns.some((pattern) => pattern.test(url))
}
/**
* RemotePageController is a proxy that implements the PageController interface.
* All methods are async and forward to ContentScript via RPC.
@@ -20,30 +50,133 @@ import { type RPCClient, createRPCClient } from '../messaging/rpc'
* This class extends EventTarget to maintain API compatibility with PageController,
* though events in the remote context are not currently bridged.
*/
export class RemotePageController extends EventTarget {
private rpc: RPCClient
private _tabId: number | null = null
private _tabIdPromise: Promise<number>
export class RemotePageController {
private rpc: RPCClient | null = null
private _currentTabId: number | null = null
private _currentTabUrl: string | undefined = undefined
private _previousTabId: number | null = null
/** Get the target tab ID (null if not yet resolved) */
get tabId(): number | null {
return this._tabId
/** Get the current target tab ID */
get currentTabId(): number | null {
return this._currentTabId
}
/** Get the promise that resolves to the target tab ID */
get tabIdPromise(): Promise<number> {
return this._tabIdPromise
/** Get the current target tab URL */
get currentTabUrl(): string | undefined {
return this._currentTabUrl
}
constructor() {
super()
// Capture the active tab ID at construction time to avoid issues when tab loses focus
this._tabIdPromise = chrome.tabs.query({ active: true, currentWindow: true }).then(([tab]) => {
if (!tab?.id) throw new Error('No active tab found')
this._tabId = tab.id
return tab.id
})
this.rpc = createRPCClient(this._tabIdPromise)
/** Check if current tab supports content scripts */
get isCurrentTabAccessible(): boolean {
return isContentScriptAllowed(this._currentTabUrl)
}
// Tab ID is now set externally via setTargetTab()
/**
* Set the target tab for all RPC operations.
* Called by TabsManager when switching tabs.
* Handles cleanup on old tab and mask show on new tab.
*/
async setTargetTab(tabId: number): Promise<void> {
const previousTabId = this._currentTabId
const previousRpc = this.rpc
console.debug(`${DEBUG_PREFIX} setTargetTab: ${previousTabId}${tabId}`)
// Clean up old tab completely (highlights + mask)
if (previousTabId && previousTabId !== tabId && previousRpc) {
console.debug(`${DEBUG_PREFIX} Cleaning up previous tab ${previousTabId}`)
try {
// Clean up highlights first - this is important for visual cleanup
await previousRpc.cleanUpHighlights()
} catch (e) {
console.debug(
`${DEBUG_PREFIX} cleanUpHighlights on tab ${previousTabId} failed (ignored):`,
e
)
}
try {
await previousRpc.hideMask()
} catch (e) {
console.debug(`${DEBUG_PREFIX} hideMask on tab ${previousTabId} failed (ignored):`, e)
}
}
// Get tab info to check URL
const tab = await chrome.tabs.get(tabId)
const tabUrl = tab.url
// Update state
this._previousTabId = previousTabId
this._currentTabId = tabId
this._currentTabUrl = tabUrl
// Check if this tab can run content scripts
if (!isContentScriptAllowed(tabUrl)) {
console.debug(`${DEBUG_PREFIX} Tab ${tabId} cannot run content scripts: ${tabUrl}`)
// Clear RPC - operations will return restricted page state
this.rpc = null
return
}
// Create new RPC client for the new tab
this.rpc = createRPCClient(tabId)
// Verify content script is ready by making a test call
// This uses the retry mechanism to wait for content script initialization
try {
await this.rpc.getLastUpdateTime()
console.debug(`${DEBUG_PREFIX} Content script ready on tab ${tabId}`)
} catch (error) {
console.error(`${DEBUG_PREFIX} Content script not ready on tab ${tabId}:`, error)
// Don't clear rpc - subsequent calls will retry and may succeed
}
// Show mask on new tab
try {
await this.rpc.showMask()
console.debug(`${DEBUG_PREFIX} Mask shown on tab ${tabId}`)
} catch (error) {
console.error(`${DEBUG_PREFIX} Failed to show mask on tab ${tabId}:`, error)
// Continue anyway - mask is optional
}
console.debug(`${DEBUG_PREFIX} Target tab set to ${tabId}`)
}
/**
* Ensure RPC client is initialized
* @throws Error if setTargetTab() has not been called
*/
private ensureInitialized(): void {
if (!this._currentTabId) {
throw new Error('RemotePageController not initialized. Call setTargetTab() first.')
}
}
/**
* Create a browser state for restricted pages that cannot run content scripts.
* Treats restricted pages as empty pages rather than errors.
*/
private createRestrictedPageState(): BrowserState {
return {
url: this._currentTabUrl || '',
title: '',
header: '',
content: '(empty page)',
footer: '',
}
}
/**
* Create a no-op action result for restricted pages
*/
private createRestrictedActionResult(action: string): ActionResult {
return {
success: false,
message: `Cannot ${action} on this page. Use open_new_tab to navigate to a web page first.`,
}
}
// ======= State Queries =======
@@ -52,13 +185,15 @@ export class RemotePageController extends EventTarget {
* Get current page URL
*/
async getCurrentUrl(): Promise<string> {
return this.rpc.getCurrentUrl()
// Can return URL even for restricted pages
return this._currentTabUrl || ''
}
/**
* Get last tree update timestamp
*/
async getLastUpdateTime(): Promise<number> {
if (!this.rpc) return Date.now()
return this.rpc.getLastUpdateTime()
}
@@ -66,6 +201,10 @@ export class RemotePageController extends EventTarget {
* Get structured browser state for LLM consumption.
*/
async getBrowserState(): Promise<BrowserState> {
// Return restricted page state if content scripts cannot run
if (!this.rpc) {
return this.createRestrictedPageState()
}
return this.rpc.getBrowserState()
}
@@ -75,6 +214,8 @@ export class RemotePageController extends EventTarget {
* Update DOM tree, returns simplified HTML for LLM.
*/
async updateTree(): Promise<string> {
this.ensureInitialized()
if (!this.rpc) return '(empty page)'
return this.rpc.updateTree()
}
@@ -82,6 +223,7 @@ export class RemotePageController extends EventTarget {
* Clean up all element highlights
*/
async cleanUpHighlights(): Promise<void> {
if (!this.rpc) return
return this.rpc.cleanUpHighlights()
}
@@ -91,6 +233,8 @@ export class RemotePageController extends EventTarget {
* Click element by index
*/
async clickElement(index: number): Promise<ActionResult> {
this.ensureInitialized()
if (!this.rpc) return this.createRestrictedActionResult('click')
return this.rpc.clickElement(index)
}
@@ -98,6 +242,8 @@ export class RemotePageController extends EventTarget {
* Input text into element by index
*/
async inputText(index: number, text: string): Promise<ActionResult> {
this.ensureInitialized()
if (!this.rpc) return this.createRestrictedActionResult('input text')
return this.rpc.inputText(index, text)
}
@@ -105,6 +251,8 @@ export class RemotePageController extends EventTarget {
* Select dropdown option by index and option text
*/
async selectOption(index: number, optionText: string): Promise<ActionResult> {
this.ensureInitialized()
if (!this.rpc) return this.createRestrictedActionResult('select option')
return this.rpc.selectOption(index, optionText)
}
@@ -112,6 +260,8 @@ export class RemotePageController extends EventTarget {
* Scroll vertically
*/
async scroll(options: ScrollOptions): Promise<ActionResult> {
this.ensureInitialized()
if (!this.rpc) return this.createRestrictedActionResult('scroll')
return this.rpc.scroll(options)
}
@@ -119,6 +269,8 @@ export class RemotePageController extends EventTarget {
* Scroll horizontally
*/
async scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult> {
this.ensureInitialized()
if (!this.rpc) return this.createRestrictedActionResult('scroll')
return this.rpc.scrollHorizontally(options)
}
@@ -126,6 +278,8 @@ export class RemotePageController extends EventTarget {
* Execute arbitrary JavaScript on the page
*/
async executeJavascript(script: string): Promise<ActionResult> {
this.ensureInitialized()
if (!this.rpc) return this.createRestrictedActionResult('execute script')
return this.rpc.executeJavascript(script)
}
@@ -135,6 +289,7 @@ export class RemotePageController extends EventTarget {
* Show the visual mask overlay.
*/
async showMask(): Promise<void> {
if (!this.rpc) return
return this.rpc.showMask()
}
@@ -142,15 +297,38 @@ export class RemotePageController extends EventTarget {
* Hide the visual mask overlay.
*/
async hideMask(): Promise<void> {
if (!this.rpc) return
return this.rpc.hideMask()
}
/**
* Dispose and clean up resources
* Dispose and clean up resources on current tab
*/
dispose(): void {
this.rpc.dispose().catch(() => {
// Ignore errors on dispose
})
console.debug(`${DEBUG_PREFIX} dispose() called, current tab: ${this._currentTabId}`)
if (this.rpc) {
this.rpc.dispose().catch((e) => {
console.debug(`${DEBUG_PREFIX} dispose RPC failed (ignored):`, e)
})
}
this._currentTabId = null
this._previousTabId = null
this.rpc = null
}
/**
* Dispose PageController on a specific tab (cleanup for multi-tab scenarios)
*/
async disposeTab(tabId: number): Promise<void> {
console.debug(`${DEBUG_PREFIX} disposeTab(${tabId})`)
try {
const rpc = createRPCClient(tabId)
await rpc.cleanUpHighlights()
await rpc.hideMask()
await rpc.dispose()
console.debug(`${DEBUG_PREFIX} Tab ${tabId} disposed successfully`)
} catch (e) {
console.debug(`${DEBUG_PREFIX} disposeTab(${tabId}) failed (ignored):`, e)
}
}
}

View File

@@ -0,0 +1,566 @@
/**
* TabsManager - Manages multiple browser tabs for agent automation
*
* Responsibilities:
* - Maintain initialTabId (tab where user started the task)
* - Maintain currentTabId (current operation target)
* - Maintain currentTabHistory (history stack for fallback)
* - Maintain managedTabIds (tabs opened by agent)
* - Manage Chrome Tab Group (named "Task(<taskId>)")
* - Listen to chrome.tabs.onRemoved for tab close handling
*/
import { type RemotePageController, isContentScriptAllowed } from './RemotePageController'
const DEBUG_PREFIX = '[TabsManager]'
/** Tab info for display in browser state */
export interface TabInfo {
id: number
url: string
title: string
isInitial: boolean
isCurrent: boolean
/** Whether content scripts can run on this page */
isAccessible: boolean
}
/** Changes since last getAndClearChanges() call */
export interface TabChanges {
opened: TabInfo[]
closed: { id: number; url: string; title: string }[]
currentSwitched?: { from: number; to: number; reason: 'user_close' | 'explicit' }
}
/** Tab group colors supported by Chrome */
const TAB_GROUP_COLORS = [
'grey',
'blue',
'red',
'yellow',
'green',
'pink',
'purple',
'cyan',
] as const
type TabGroupColor = (typeof TAB_GROUP_COLORS)[number]
function randomColor(): TabGroupColor {
return TAB_GROUP_COLORS[Math.floor(Math.random() * TAB_GROUP_COLORS.length)]
}
export class TabsManager {
/** Tab where user started the task */
private initialTabId: number | null = null
/** Current operation target tab */
private currentTabId: number | null = null
/** History stack for current tab (for fallback on close) */
private currentTabHistory: number[] = []
/** Tabs opened by agent (not including initial tab) */
private managedTabIds = new Set<number>()
/** Tab group ID for managed tabs */
private tabGroupId: number | null = null
/** Task ID for group naming */
private taskId: string = ''
/** Reference to RemotePageController for tab switching */
private pageController: RemotePageController | null = null
/** Pending changes for observation generation */
private pendingChanges: TabChanges = { opened: [], closed: [] }
/** Tab info cache for closed tab reporting */
private tabInfoCache = new Map<number, { url: string; title: string }>()
/** Whether manager is disposed */
private disposed = false
/** Bound handler for cleanup */
private onTabRemovedHandler: (tabId: number) => void
constructor() {
this.onTabRemovedHandler = this.onTabRemoved.bind(this)
}
/**
* Initialize the manager with current active tab
*/
async init(taskId: string, pageController: RemotePageController): Promise<void> {
this.taskId = taskId
this.pageController = pageController
this.disposed = false
// Get current active tab as initial tab
const [activeTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
})
if (!activeTab?.id) {
throw new Error('No active tab found')
}
this.initialTabId = activeTab.id
this.currentTabId = activeTab.id
this.currentTabHistory = []
this.managedTabIds.clear()
this.pendingChanges = { opened: [], closed: [] }
// Cache initial tab info
this.tabInfoCache.set(activeTab.id, {
url: activeTab.url || '',
title: activeTab.title || '',
})
// Set target tab on page controller
await pageController.setTargetTab(activeTab.id)
// Register tab removal listener
chrome.tabs.onRemoved.addListener(this.onTabRemovedHandler)
console.debug(`${DEBUG_PREFIX} Initialized with tab:`, activeTab.id)
}
/**
* Open a new tab and set it as current
*/
async openNewTab(url: string): Promise<{ tabId: number; message: string }> {
if (!this.initialTabId || !this.pageController) {
throw new Error('TabsManager not initialized')
}
// Create new tab next to current tab
const newTab = await chrome.tabs.create({
url,
active: false, // Don't activate - agent controls focus via mask
openerTabId: this.currentTabId ?? this.initialTabId,
})
if (!newTab.id) {
throw new Error('Failed to create new tab')
}
const tabId = newTab.id
// Add to managed tabs
this.managedTabIds.add(tabId)
// Create or update tab group
await this.ensureTabGroup(tabId)
// Wait for page to complete loading before switching
// This ensures content script is ready when we set target tab
await this.waitForTabComplete(tabId)
// Get updated tab info after load
const loadedTab = await chrome.tabs.get(tabId)
const loadedUrl = loadedTab.url || url
// Cache tab info
this.tabInfoCache.set(tabId, {
url: loadedUrl,
title: loadedTab.title || url,
})
// Record change
this.pendingChanges.opened.push({
id: tabId,
url: loadedUrl,
title: loadedTab.title || url,
isInitial: false,
isCurrent: true,
isAccessible: isContentScriptAllowed(loadedUrl),
})
// Switch to new tab (content script should be ready now)
await this.switchToTab(tabId)
return {
tabId,
message: `Opened new tab [${tabId}] with URL: ${url}`,
}
}
/**
* Wait for a tab to complete loading
*/
private waitForTabComplete(tabId: number, timeoutMs = 30_000): Promise<void> {
return new Promise((resolve, reject) => {
let resolved = false
const cleanup = () => {
if (!resolved) {
resolved = true
clearTimeout(timeout)
chrome.tabs.onUpdated.removeListener(listener)
}
}
const timeout = setTimeout(() => {
cleanup()
reject(new Error(`Tab ${tabId} did not complete loading within ${timeoutMs}ms`))
}, timeoutMs)
const listener = (updatedTabId: number, changeInfo: { status?: string }) => {
if (updatedTabId === tabId && changeInfo.status === 'complete') {
cleanup()
resolve()
}
}
// Add listener FIRST to avoid race condition
chrome.tabs.onUpdated.addListener(listener)
// Then check if already complete
chrome.tabs
.get(tabId)
.then((tab) => {
if (tab.status === 'complete' && !resolved) {
cleanup()
resolve()
}
})
.catch((error: unknown) => {
cleanup()
reject(error instanceof Error ? error : new Error(String(error)))
})
})
}
/**
* Switch current tab to specified tab
*/
async switchToTab(tabId: number): Promise<string> {
if (!this.pageController) {
throw new Error('TabsManager not initialized')
}
// Verify tab exists
try {
await chrome.tabs.get(tabId)
} catch {
throw new Error(`Tab ${tabId} does not exist`)
}
// Verify tab is in our control list
if (tabId !== this.initialTabId && !this.managedTabIds.has(tabId)) {
throw new Error(
`Tab ${tabId} is not in the managed tab list. Only initial tab and tabs opened by agent can be switched to.`
)
}
const previousTabId = this.currentTabId
// Push current to history (if different)
if (this.currentTabId && this.currentTabId !== tabId) {
this.currentTabHistory.push(this.currentTabId)
}
this.currentTabId = tabId
// Update page controller target
await this.pageController.setTargetTab(tabId)
// Update tab info cache
const tab = await chrome.tabs.get(tabId)
this.tabInfoCache.set(tabId, {
url: tab.url || '',
title: tab.title || '',
})
console.debug(`${DEBUG_PREFIX} Switched to tab:`, tabId)
return `Switched to tab [${tabId}]${previousTabId ? ` (from tab [${previousTabId}])` : ''}`
}
/**
* Close a tab, optionally switch to specified tab
*/
async closeTab(tabId: number, switchTo?: number): Promise<string> {
if (!this.pageController) {
throw new Error('TabsManager not initialized')
}
// Cannot close initial tab
if (tabId === this.initialTabId) {
throw new Error('Cannot close the initial tab')
}
// Verify tab is managed
if (!this.managedTabIds.has(tabId)) {
throw new Error(`Tab ${tabId} is not in the managed tab list`)
}
// Get tab info before closing
const tabInfo = this.tabInfoCache.get(tabId)
// If closing current tab, determine switch target
if (tabId === this.currentTabId) {
const targetTabId = switchTo ?? this.findFallbackTab(tabId)
if (targetTabId) {
await this.switchToTab(targetTabId)
}
}
// Close the tab
await chrome.tabs.remove(tabId)
// Clean up
this.managedTabIds.delete(tabId)
this.tabInfoCache.delete(tabId)
this.currentTabHistory = this.currentTabHistory.filter((id) => id !== tabId)
// Record change
if (tabInfo) {
this.pendingChanges.closed.push({
id: tabId,
url: tabInfo.url,
title: tabInfo.title,
})
}
return `Closed tab [${tabId}]${switchTo ? ` and switched to tab [${switchTo}]` : ''}`
}
/**
* Get list of all tabs under control
*/
async getTabList(): Promise<TabInfo[]> {
const tabs: TabInfo[] = []
// Add initial tab
if (this.initialTabId) {
try {
const tab = await chrome.tabs.get(this.initialTabId)
const url = tab.url || ''
tabs.push({
id: tab.id!,
url,
title: tab.title || '',
isInitial: true,
isCurrent: tab.id === this.currentTabId,
isAccessible: isContentScriptAllowed(url),
})
// Update cache
this.tabInfoCache.set(tab.id!, { url, title: tab.title || '' })
} catch {
// Initial tab was closed - will be handled by onRemoved
}
}
// Add managed tabs
for (const tabId of this.managedTabIds) {
try {
const tab = await chrome.tabs.get(tabId)
const url = tab.url || ''
tabs.push({
id: tab.id!,
url,
title: tab.title || '',
isInitial: false,
isCurrent: tab.id === this.currentTabId,
isAccessible: isContentScriptAllowed(url),
})
// Update cache
this.tabInfoCache.set(tab.id!, { url, title: tab.title || '' })
} catch {
// Tab was closed - will be handled by onRemoved
}
}
return tabs
}
/**
* Get current tab ID
*/
getCurrentTabId(): number | null {
return this.currentTabId
}
/**
* Get and clear pending changes (for observation generation)
*/
getAndClearChanges(): TabChanges {
const changes = this.pendingChanges
this.pendingChanges = { opened: [], closed: [] }
return changes
}
/**
* Check if a tab is managed by this manager (initial or opened by agent)
*/
isTabManaged(tabId: number): boolean {
return tabId === this.initialTabId || this.managedTabIds.has(tabId)
}
/**
* Get all managed tab IDs (initial + agent-opened tabs)
*/
getAllManagedTabIds(): number[] {
const ids: number[] = []
if (this.initialTabId) ids.push(this.initialTabId)
for (const id of this.managedTabIds) {
ids.push(id)
}
return ids
}
/**
* Dispose PageController on all managed tabs.
* This cleans up highlights and masks on every tab.
* Should be called before dispose() to ensure clean state.
*/
async disposeAllPageControllers(): Promise<void> {
if (!this.pageController) return
const allTabIds = this.getAllManagedTabIds()
console.debug(
`${DEBUG_PREFIX} Disposing PageControllers on ${allTabIds.length} tabs:`,
allTabIds
)
// Dispose each tab in parallel
await Promise.all(
allTabIds.map((tabId) =>
this.pageController!.disposeTab(tabId).catch((e) => {
console.debug(`${DEBUG_PREFIX} disposeTab(${tabId}) failed:`, e)
})
)
)
console.debug(`${DEBUG_PREFIX} All PageControllers disposed`)
}
/**
* Dispose manager and clean up
* Note: Tab group is intentionally kept - only internal state is cleared
*/
dispose(): void {
if (this.disposed) return
this.disposed = true
console.debug(`${DEBUG_PREFIX} dispose() called`)
// Remove listener
chrome.tabs.onRemoved.removeListener(this.onTabRemovedHandler)
// Clear internal state only - keep tab group intact for user
this.initialTabId = null
this.currentTabId = null
this.currentTabHistory = []
this.managedTabIds.clear()
this.tabGroupId = null
this.pageController = null
this.tabInfoCache.clear()
this.pendingChanges = { opened: [], closed: [] }
console.debug(`${DEBUG_PREFIX} Disposed`)
}
/**
* Handle tab removal event
*/
private async onTabRemoved(tabId: number): Promise<void> {
if (this.disposed) return
// Check if it's a tab we care about
const isInitial = tabId === this.initialTabId
const isManaged = this.managedTabIds.has(tabId)
if (!isInitial && !isManaged) return
console.debug(`${DEBUG_PREFIX} Tab removed:`, tabId, { isInitial, isManaged })
// Get cached info for change reporting
const tabInfo = this.tabInfoCache.get(tabId)
if (tabInfo) {
this.pendingChanges.closed.push({
id: tabId,
url: tabInfo.url,
title: tabInfo.title,
})
}
// Clean up
this.managedTabIds.delete(tabId)
this.tabInfoCache.delete(tabId)
this.currentTabHistory = this.currentTabHistory.filter((id) => id !== tabId)
// If initial tab was closed, this is fatal
if (isInitial) {
this.initialTabId = null
console.error(`${DEBUG_PREFIX} Initial tab was closed - task should fail`)
// The agent will detect this via getTabList() and handle appropriately
return
}
// If current tab was closed, fallback to previous
if (tabId === this.currentTabId && this.pageController) {
const fallbackTabId = this.findFallbackTab(tabId)
if (fallbackTabId) {
this.pendingChanges.currentSwitched = {
from: tabId,
to: fallbackTabId,
reason: 'user_close',
}
// Don't await - fire and forget to avoid blocking
this.switchToTab(fallbackTabId).catch(() => {
// Ignore - tab switch failed but we're already in error recovery
})
}
}
}
/**
* Find fallback tab when current tab is closed
*/
private findFallbackTab(closedTabId: number): number | null {
// Try history stack (most recent first)
while (this.currentTabHistory.length > 0) {
const tabId = this.currentTabHistory.pop()!
if (tabId !== closedTabId && (tabId === this.initialTabId || this.managedTabIds.has(tabId))) {
return tabId
}
}
// Fall back to initial tab
if (this.initialTabId && this.initialTabId !== closedTabId) {
return this.initialTabId
}
return null
}
/**
* Ensure tab group exists and add tab to it
*/
private async ensureTabGroup(tabId: number): Promise<void> {
try {
if (this.tabGroupId === null) {
// Create new group
this.tabGroupId = await chrome.tabs.group({ tabIds: [tabId] })
// Set group properties
await chrome.tabGroups.update(this.tabGroupId, {
title: `Task(${this.taskId.slice(0, 8)})`,
color: randomColor(),
collapsed: false,
})
console.debug(`${DEBUG_PREFIX} Created tab group:`, this.tabGroupId)
} else {
// Add to existing group
await chrome.tabs.group({
tabIds: [tabId],
groupId: this.tabGroupId,
})
}
} catch (error) {
console.debug(`${DEBUG_PREFIX} Failed to manage tab group:`, error)
// Non-fatal - continue without grouping
}
}
}

View File

@@ -0,0 +1,70 @@
/**
* Tab control tools for browser extension
*
* These tools allow the agent to manage multiple browser tabs:
* - open_new_tab: Open a new tab and set it as current
* - switch_to_tab: Switch to an existing tab
* - close_tab: Close a tab (optionally switch to another)
*/
import zod from 'zod'
import type { TabsManager } from './TabsManager'
/** Tool definition compatible with PageAgentCore customTools */
interface TabTool {
description: string
inputSchema: zod.ZodType
execute: (input: unknown) => Promise<string>
}
/**
* Create tab control tools bound to a TabsManager instance.
* These tools are injected into PageAgentCore via customTools config.
*/
export function createTabTools(tabsManager: TabsManager): Record<string, TabTool> {
return {
open_new_tab: {
description:
'Open a new browser tab with the specified URL. The new tab becomes the current tab for all subsequent page operations.',
inputSchema: zod.object({
url: zod.string().describe('The URL to open in the new tab'),
}),
execute: async (input: unknown) => {
const { url } = input as { url: string }
const result = await tabsManager.openNewTab(url)
return result.message
},
},
switch_to_tab: {
description:
'Switch to an existing tab by its ID. After switching, all page operations will target the new current tab. You can only switch to tabs in the tab list shown in browser state.',
inputSchema: zod.object({
tab_id: zod.number().int().describe('The tab ID to switch to'),
}),
execute: async (input: unknown) => {
const { tab_id } = input as { tab_id: number }
return tabsManager.switchToTab(tab_id)
},
},
close_tab: {
description:
'Close a tab by its ID. Cannot close the initial tab. Optionally specify which tab to switch to after closing.',
inputSchema: zod.object({
tab_id: zod.number().int().describe('The tab ID to close'),
switch_to: zod
.number()
.int()
.optional()
.describe(
'Optional: Tab ID to switch to after closing. If not specified, will switch to previous tab in history.'
),
}),
execute: async (input: unknown) => {
const { tab_id, switch_to } = input as { tab_id: number; switch_to?: number }
return tabsManager.closeTab(tab_id, switch_to)
},
},
}
}